mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
- Updated the `extreme_filesystem_5_file_rename_chains` test to ignore execution on macOS due to kqueue limitations with high rename pressure. - Added documentation to clarify the reason for the macOS-specific ignore condition, ensuring better understanding of the test's behavior across different platforms.
5026 lines
180 KiB
Rust
5026 lines
180 KiB
Rust
//! Integration tests for the two-way sync pipeline (POST /api/write).
|
||
//!
|
||
//! These tests validate the core operations that allow the plugin's two-way
|
||
//! sync to eventually replace `rojo syncback`:
|
||
//!
|
||
//! 1. Source property writes to disk
|
||
//! 2. Non-Source property persistence to meta files
|
||
//! 3. Instance rename → file rename
|
||
//! 4. ClassName transitions → file extension rename
|
||
//! 5. Instance removal with meta file cleanup
|
||
//! 6. End-to-end round-trip lifecycle (modify, rename, remove)
|
||
//! 7. ProjectNode guard (no project file corruption)
|
||
//! 8. Echo suppression (no redundant VFS patches)
|
||
//!
|
||
//! NOTE: Tests operate on pre-existing instances already in the Rojo tree
|
||
//! (from the initial snapshot). Adding new instances via the API writes files
|
||
//! to disk but relies on VFS events for tree updates; with echo suppression,
|
||
//! those events are suppressed for the paths the API wrote. This is correct
|
||
//! behavior — the plugin reconciles its own tree and doesn't need Rojo to
|
||
//! re-snapshot what it just sent. Tests that need to interact with the tree
|
||
//! (read back IDs, send updates) must use instances from the initial snapshot.
|
||
|
||
use std::collections::HashMap;
|
||
use std::path::Path;
|
||
use std::time::{Duration, Instant};
|
||
use std::{fs, thread};
|
||
|
||
use librojo::web_api::{AddedInstance, InstanceUpdate, WriteRequest};
|
||
use rbx_dom_weak::types::{Ref, Variant};
|
||
use rbx_dom_weak::{ustr, UstrMap};
|
||
|
||
use crate::rojo_test::serve_util::run_serve_test;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Platform-tuned constants for stress tests
|
||
//
|
||
// On macOS, kqueue delivers more granular events than FSEvents (per-file
|
||
// vnode changes instead of coalesced directory batches). This generates
|
||
// significantly more VFS events during rapid rename/write sequences, so
|
||
// stress tests need wider inter-operation gaps and longer poll timeouts.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Delay between rapid filesystem operations in stress tests (ms).
|
||
#[cfg(target_os = "macos")]
|
||
const STRESS_OP_DELAY_MS: u64 = 250;
|
||
#[cfg(not(target_os = "macos"))]
|
||
const STRESS_OP_DELAY_MS: u64 = 30;
|
||
|
||
/// Poll timeout for stress tests that wait for the tree to settle (ms).
|
||
/// macOS kqueue generates per-file vnode events for every rename, and each
|
||
/// event triggers a re-snapshot of the parent directory. Under heavy CI load
|
||
/// (e.g., 5-file simultaneous rename chains), 15s can be insufficient.
|
||
#[cfg(target_os = "macos")]
|
||
const STRESS_POLL_TIMEOUT_MS: u64 = 30000;
|
||
#[cfg(not(target_os = "macos"))]
|
||
const STRESS_POLL_TIMEOUT_MS: u64 = 5000;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Find an instance by class name in the read response.
|
||
fn find_by_class<'a>(
|
||
instances: &'a HashMap<Ref, librojo::web_api::Instance<'a>>,
|
||
class_name: &str,
|
||
) -> (Ref, &'a librojo::web_api::Instance<'a>) {
|
||
instances
|
||
.iter()
|
||
.find(|(_, inst)| inst.class_name == class_name)
|
||
.map(|(id, inst)| (*id, inst))
|
||
.unwrap_or_else(|| panic!("Instance with class '{}' not found", class_name))
|
||
}
|
||
|
||
/// Find an instance by name in the read response.
|
||
fn find_by_name<'a>(
|
||
instances: &'a HashMap<Ref, librojo::web_api::Instance<'a>>,
|
||
name: &str,
|
||
) -> (Ref, &'a librojo::web_api::Instance<'a>) {
|
||
instances
|
||
.iter()
|
||
.find(|(_, inst)| inst.name == name)
|
||
.map(|(id, inst)| (*id, inst))
|
||
.unwrap_or_else(|| {
|
||
let available: Vec<&str> = instances.values().map(|i| i.name.as_ref()).collect();
|
||
panic!(
|
||
"Instance with name '{}' not found. Available: {:?}",
|
||
name, available
|
||
)
|
||
})
|
||
}
|
||
|
||
/// Send an InstanceUpdate via the write API and wait for processing.
|
||
fn send_update(
|
||
session: &crate::rojo_test::serve_util::TestServeSession,
|
||
session_id: &librojo::SessionId,
|
||
update: InstanceUpdate,
|
||
) {
|
||
let write_request = WriteRequest {
|
||
session_id: *session_id,
|
||
removed: vec![],
|
||
added: HashMap::new(),
|
||
updated: vec![update],
|
||
};
|
||
session
|
||
.post_api_write(&write_request)
|
||
.expect("Write request should succeed");
|
||
thread::sleep(Duration::from_millis(300));
|
||
}
|
||
|
||
/// Send a removal via the write API and wait for processing.
|
||
fn send_removal(
|
||
session: &crate::rojo_test::serve_util::TestServeSession,
|
||
session_id: &librojo::SessionId,
|
||
ids: Vec<Ref>,
|
||
) {
|
||
let write_request = WriteRequest {
|
||
session_id: *session_id,
|
||
removed: ids,
|
||
added: HashMap::new(),
|
||
updated: vec![],
|
||
};
|
||
session
|
||
.post_api_write(&write_request)
|
||
.expect("Write request should succeed");
|
||
thread::sleep(Duration::from_millis(300));
|
||
}
|
||
|
||
fn assert_file_exists(path: &Path, msg: &str) {
|
||
assert!(
|
||
path.exists() && path.is_file(),
|
||
"{}: expected file at {}",
|
||
msg,
|
||
path.display()
|
||
);
|
||
}
|
||
|
||
fn assert_not_exists(path: &Path, msg: &str) {
|
||
assert!(
|
||
!path.exists(),
|
||
"{}: should NOT exist: {}",
|
||
msg,
|
||
path.display()
|
||
);
|
||
}
|
||
|
||
/// Poll timeout for assertions on filesystem changes driven by the
|
||
/// ChangeProcessor (runs on a separate thread from the API handler).
|
||
const API_POLL_TIMEOUT_MS: u64 = 5000;
|
||
|
||
/// Poll until a file exists on disk. Use after `send_update` for operations
|
||
/// processed asynchronously by the ChangeProcessor (Source writes, renames,
|
||
/// ClassName changes). Panics after `API_POLL_TIMEOUT_MS`.
|
||
fn poll_file_exists(path: &Path, msg: &str) {
|
||
let start = Instant::now();
|
||
loop {
|
||
if path.exists() && path.is_file() {
|
||
return;
|
||
}
|
||
if start.elapsed() > Duration::from_millis(API_POLL_TIMEOUT_MS) {
|
||
panic!(
|
||
"{}: expected file at {} (timed out after {}ms)",
|
||
msg,
|
||
path.display(),
|
||
API_POLL_TIMEOUT_MS
|
||
);
|
||
}
|
||
thread::sleep(Duration::from_millis(50));
|
||
}
|
||
}
|
||
|
||
/// Poll until a path no longer exists on disk. Use after `send_update` for
|
||
/// operations processed asynchronously by the ChangeProcessor.
|
||
/// Panics after `API_POLL_TIMEOUT_MS`.
|
||
fn poll_not_exists(path: &Path, msg: &str) {
|
||
let start = Instant::now();
|
||
loop {
|
||
if !path.exists() {
|
||
return;
|
||
}
|
||
if start.elapsed() > Duration::from_millis(API_POLL_TIMEOUT_MS) {
|
||
panic!(
|
||
"{}: should NOT exist: {} (timed out after {}ms)",
|
||
msg,
|
||
path.display(),
|
||
API_POLL_TIMEOUT_MS
|
||
);
|
||
}
|
||
thread::sleep(Duration::from_millis(50));
|
||
}
|
||
}
|
||
|
||
/// Poll until a file exists and its contents contain the expected substring.
|
||
/// Use after `send_update` for Source property writes processed asynchronously
|
||
/// by the ChangeProcessor. Panics after `API_POLL_TIMEOUT_MS`.
|
||
fn poll_file_contains(path: &Path, expected: &str, msg: &str) {
|
||
let start = Instant::now();
|
||
loop {
|
||
if path.exists() && path.is_file() {
|
||
if let Ok(content) = fs::read_to_string(path) {
|
||
if content.contains(expected) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
if start.elapsed() > Duration::from_millis(API_POLL_TIMEOUT_MS) {
|
||
let content = fs::read_to_string(path).unwrap_or_else(|_| "<file not found>".into());
|
||
panic!(
|
||
"{}: file at {} should contain '{}', got: {} (timed out after {}ms)",
|
||
msg,
|
||
path.display(),
|
||
expected,
|
||
content,
|
||
API_POLL_TIMEOUT_MS
|
||
);
|
||
}
|
||
thread::sleep(Duration::from_millis(50));
|
||
}
|
||
}
|
||
|
||
/// Get ReplicatedStorage ID and the ID of the "existing" ModuleScript.
|
||
fn get_rs_and_existing(
|
||
session: &crate::rojo_test::serve_util::TestServeSession,
|
||
) -> (librojo::SessionId, Ref, Ref) {
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let (existing_id, _) = find_by_name(&rs_read.instances, "existing");
|
||
(info.session_id, rs_id, existing_id)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Test 1: The most fundamental two-way sync operation — update Source and
|
||
/// verify the file content changes on disk.
|
||
#[test]
|
||
fn update_source_writes_to_disk() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
|
||
let file_path = session.path().join("src").join("existing.luau");
|
||
assert_file_exists(&file_path, "existing.luau before update");
|
||
|
||
// Send Source update
|
||
let mut props = UstrMap::default();
|
||
props.insert(
|
||
ustr("Source"),
|
||
Some(Variant::String(
|
||
"-- Updated via two-way sync\nreturn { updated = true }".to_string(),
|
||
)),
|
||
);
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: existing_id,
|
||
changed_name: None,
|
||
changed_class_name: None,
|
||
changed_properties: props,
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
poll_file_contains(
|
||
&file_path,
|
||
"Updated via two-way sync",
|
||
"File should contain new Source",
|
||
);
|
||
});
|
||
}
|
||
|
||
/// Test 2: Non-Source property updates create an adjacent meta file.
|
||
#[test]
|
||
fn update_non_source_property_creates_meta_file() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
|
||
let file_path = session.path().join("src").join("existing.luau");
|
||
let meta_path = session.path().join("src").join("existing.meta.json5");
|
||
|
||
assert_not_exists(&meta_path, "Meta file before update");
|
||
let original_content = fs::read_to_string(&file_path).unwrap();
|
||
|
||
// Send a non-Source property update (Attributes)
|
||
let mut attrs = rbx_dom_weak::types::Attributes::new();
|
||
attrs.insert("TestAttribute".to_string(), Variant::Float64(42.0));
|
||
|
||
let mut props = UstrMap::default();
|
||
props.insert(ustr("Attributes"), Some(Variant::Attributes(attrs)));
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: existing_id,
|
||
changed_name: None,
|
||
changed_class_name: None,
|
||
changed_properties: props,
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
// Meta file should now exist with the attribute
|
||
assert_file_exists(&meta_path, "Meta file after property update");
|
||
let meta_content = fs::read_to_string(&meta_path).unwrap();
|
||
assert!(
|
||
meta_content.contains("TestAttribute"),
|
||
"Meta should contain attribute, got: {}",
|
||
meta_content
|
||
);
|
||
|
||
// Script file should be unchanged
|
||
let current = fs::read_to_string(&file_path).unwrap();
|
||
assert_eq!(original_content, current, "Script file should not change");
|
||
});
|
||
}
|
||
|
||
/// Test 3: Non-Source property update on a directory-format instance writes
|
||
/// to init.meta.json5 inside the directory.
|
||
#[test]
|
||
fn update_properties_for_directory_instance_writes_init_meta() {
|
||
run_serve_test("syncback_format_transitions", |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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
|
||
// DirModelWithChildren is a Configuration directory that IS in the tree
|
||
let (dir_model_id, _) = find_by_name(&rs_read.instances, "DirModelWithChildren");
|
||
|
||
let dir_path = session.path().join("src").join("DirModelWithChildren");
|
||
let init_meta = dir_path.join("init.meta.json5");
|
||
|
||
// Send a non-Source property update
|
||
let mut attrs = rbx_dom_weak::types::Attributes::new();
|
||
attrs.insert("DirTestAttr".to_string(), Variant::Float64(99.0));
|
||
|
||
let mut props = UstrMap::default();
|
||
props.insert(ustr("Attributes"), Some(Variant::Attributes(attrs)));
|
||
send_update(
|
||
&session,
|
||
&info.session_id,
|
||
InstanceUpdate {
|
||
id: dir_model_id,
|
||
changed_name: None,
|
||
changed_class_name: None,
|
||
changed_properties: props,
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
assert_file_exists(&init_meta, "init.meta.json5 after property update");
|
||
let meta_content = fs::read_to_string(&init_meta).unwrap();
|
||
assert!(
|
||
meta_content.contains("DirTestAttr"),
|
||
"init.meta.json5 should contain attribute, got: {}",
|
||
meta_content
|
||
);
|
||
});
|
||
}
|
||
|
||
/// Test 4: Renaming an instance renames the file on disk.
|
||
#[test]
|
||
fn rename_standalone_script() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
|
||
let old_path = session.path().join("src").join("existing.luau");
|
||
let new_path = session.path().join("src").join("renamed_module.luau");
|
||
|
||
assert_file_exists(&old_path, "existing.luau before rename");
|
||
let original_content = fs::read_to_string(&old_path).unwrap();
|
||
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: existing_id,
|
||
changed_name: Some("renamed_module".to_string()),
|
||
changed_class_name: None,
|
||
changed_properties: UstrMap::default(),
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
poll_not_exists(&old_path, "Old file after rename");
|
||
poll_file_exists(&new_path, "New file after rename");
|
||
|
||
let new_content = fs::read_to_string(&new_path).unwrap();
|
||
assert_eq!(
|
||
original_content, new_content,
|
||
"Content preserved after rename"
|
||
);
|
||
});
|
||
}
|
||
|
||
/// Test 5: Renaming an instance also renames its adjacent meta file.
|
||
#[test]
|
||
fn rename_preserves_adjacent_meta_file() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
|
||
// First, create a meta file by sending a non-Source property update
|
||
let mut attrs = rbx_dom_weak::types::Attributes::new();
|
||
attrs.insert("SomeAttr".to_string(), Variant::Bool(true));
|
||
let mut props = UstrMap::default();
|
||
props.insert(ustr("Attributes"), Some(Variant::Attributes(attrs)));
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: existing_id,
|
||
changed_name: None,
|
||
changed_class_name: None,
|
||
changed_properties: props,
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
let old_meta = session.path().join("src").join("existing.meta.json5");
|
||
assert_file_exists(&old_meta, "Meta file before rename");
|
||
|
||
// Re-read tree to get current ID (may have changed after re-snapshot)
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
|
||
// Now rename — the meta file name won't match "existing" anymore
|
||
// because the instance is now at "renamed_meta"
|
||
// Note: we need to find the instance by its CURRENT name
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: existing_id,
|
||
changed_name: Some("renamed_meta".to_string()),
|
||
changed_class_name: None,
|
||
changed_properties: UstrMap::default(),
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
let new_script = session.path().join("src").join("renamed_meta.luau");
|
||
let new_meta = session.path().join("src").join("renamed_meta.meta.json5");
|
||
|
||
poll_not_exists(
|
||
&session.path().join("src").join("existing.luau"),
|
||
"Old script gone",
|
||
);
|
||
poll_not_exists(&old_meta, "Old meta gone");
|
||
poll_file_exists(&new_script, "Renamed script exists");
|
||
poll_file_exists(&new_meta, "Renamed meta exists");
|
||
});
|
||
}
|
||
|
||
/// Test 6: Changing ClassName from ModuleScript to Script renames the file extension.
|
||
#[test]
|
||
fn classname_change_module_to_script() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
|
||
let module_path = session.path().join("src").join("existing.luau");
|
||
let script_path = session.path().join("src").join("existing.server.luau");
|
||
|
||
assert_file_exists(&module_path, "Module file before class change");
|
||
assert_not_exists(&script_path, "Script file before class change");
|
||
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: existing_id,
|
||
changed_name: None,
|
||
changed_class_name: Some(ustr("Script")),
|
||
changed_properties: UstrMap::default(),
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
poll_not_exists(&module_path, "Module file after class change");
|
||
poll_file_exists(&script_path, "Script file after class change");
|
||
});
|
||
}
|
||
|
||
/// Test 7: Changing ClassName from ModuleScript to LocalScript.
|
||
#[test]
|
||
fn classname_change_module_to_localscript() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
|
||
let module_path = session.path().join("src").join("existing.luau");
|
||
let local_path = session.path().join("src").join("existing.local.luau");
|
||
|
||
assert_file_exists(&module_path, "Module file before class change");
|
||
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: existing_id,
|
||
changed_name: None,
|
||
changed_class_name: Some(ustr("LocalScript")),
|
||
changed_properties: UstrMap::default(),
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
poll_not_exists(&module_path, "Module file after class change");
|
||
poll_file_exists(&local_path, "LocalScript file after class change");
|
||
});
|
||
}
|
||
|
||
/// Test 8: Removing an instance deletes both its file and adjacent meta file.
|
||
#[test]
|
||
fn remove_instance_deletes_file_and_meta() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
|
||
let file_path = session.path().join("src").join("existing.luau");
|
||
assert_file_exists(&file_path, "File before removal");
|
||
|
||
// Create meta file via property update
|
||
let mut attrs = rbx_dom_weak::types::Attributes::new();
|
||
attrs.insert("Temp".to_string(), Variant::Bool(true));
|
||
let mut props = UstrMap::default();
|
||
props.insert(ustr("Attributes"), Some(Variant::Attributes(attrs)));
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: existing_id,
|
||
changed_name: None,
|
||
changed_class_name: None,
|
||
changed_properties: props,
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
let meta_path = session.path().join("src").join("existing.meta.json5");
|
||
assert_file_exists(&meta_path, "Meta file before removal");
|
||
|
||
// Re-read ID in case tree was rebuilt
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
send_removal(&session, &session_id, vec![existing_id]);
|
||
|
||
assert_not_exists(&file_path, "File after removal");
|
||
assert_not_exists(&meta_path, "Meta file after removal");
|
||
});
|
||
}
|
||
|
||
/// Test 9: Removing a directory-format instance deletes the entire directory.
|
||
#[test]
|
||
fn remove_directory_instance() {
|
||
run_serve_test("syncback_format_transitions", |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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
|
||
// DirModelWithChildren is a Configuration directory in the tree
|
||
let (dir_model_id, _) = find_by_name(&rs_read.instances, "DirModelWithChildren");
|
||
|
||
let dir_path = session.path().join("src").join("DirModelWithChildren");
|
||
assert!(dir_path.is_dir(), "Directory should exist before removal");
|
||
|
||
send_removal(&session, &info.session_id, vec![dir_model_id]);
|
||
|
||
assert_not_exists(&dir_path, "Directory after removal");
|
||
});
|
||
}
|
||
|
||
/// Test 10: End-to-end round-trip — modify Source, rename, then remove.
|
||
/// Validates the complete lifecycle on a single instance.
|
||
#[test]
|
||
fn round_trip_modify_rename_remove() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
let src = session.path().join("src");
|
||
|
||
// Step 1: MODIFY Source
|
||
let mut props = UstrMap::default();
|
||
props.insert(
|
||
ustr("Source"),
|
||
Some(Variant::String("-- Round trip v1".to_string())),
|
||
);
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: existing_id,
|
||
changed_name: None,
|
||
changed_class_name: None,
|
||
changed_properties: props,
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
let file = src.join("existing.luau");
|
||
poll_file_contains(&file, "Round trip v1", "Step 1: Source updated");
|
||
|
||
// Re-read to get current ID
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
|
||
// Step 2: RENAME
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: existing_id,
|
||
changed_name: Some("GameService".to_string()),
|
||
changed_class_name: None,
|
||
changed_properties: UstrMap::default(),
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
poll_not_exists(&file, "Step 2: old file gone");
|
||
let new_file = src.join("GameService.luau");
|
||
poll_file_exists(&new_file, "Step 2: renamed file exists");
|
||
let content = fs::read_to_string(&new_file).unwrap();
|
||
assert!(
|
||
content.contains("Round trip v1"),
|
||
"Step 2: content preserved after rename"
|
||
);
|
||
|
||
// Re-read to get current ID after rename
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let (game_service_id, _) = find_by_name(&rs_read.instances, "GameService");
|
||
|
||
// Step 3: REMOVE
|
||
send_removal(&session, &info.session_id, vec![game_service_id]);
|
||
assert_not_exists(&new_file, "Step 3: file removed");
|
||
});
|
||
}
|
||
|
||
/// Test 11: Property updates on project-defined instances (Services) should
|
||
/// NOT corrupt the project file.
|
||
#[test]
|
||
fn property_update_skips_project_node_instances() {
|
||
run_serve_test("syncback_write", |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");
|
||
|
||
// Save original project file content
|
||
let project_path = session.path().join("default.project.json5");
|
||
let original_project = fs::read_to_string(&project_path).unwrap();
|
||
|
||
// Send a non-Source property update to the service
|
||
let mut attrs = rbx_dom_weak::types::Attributes::new();
|
||
attrs.insert("ServiceAttr".to_string(), Variant::Float64(1.0));
|
||
let mut props = UstrMap::default();
|
||
props.insert(ustr("Attributes"), Some(Variant::Attributes(attrs)));
|
||
|
||
send_update(
|
||
&session,
|
||
&info.session_id,
|
||
InstanceUpdate {
|
||
id: rs_id,
|
||
changed_name: None,
|
||
changed_class_name: None,
|
||
changed_properties: props,
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
// Project file should be UNCHANGED
|
||
let current_project = fs::read_to_string(&project_path).unwrap();
|
||
assert_eq!(
|
||
original_project, current_project,
|
||
"Project file must not be modified by property updates to project-defined instances"
|
||
);
|
||
});
|
||
}
|
||
|
||
/// Test 12: Echo suppression — adding an instance should not cause the server
|
||
/// to become unresponsive from echo loops.
|
||
#[test]
|
||
fn echo_suppression_prevents_redundant_patches() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let info = session.get_api_rojo().unwrap();
|
||
let root_id = info.root_instance_id;
|
||
let root_read = session.get_api_read(root_id).unwrap();
|
||
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
|
||
|
||
let initial_cursor = root_read.message_cursor;
|
||
|
||
// Add a new instance (creates file on disk → VFS event → should be suppressed)
|
||
let mut properties = HashMap::new();
|
||
properties.insert(
|
||
"Source".to_string(),
|
||
Variant::String("return 'echo test'".to_string()),
|
||
);
|
||
let added = AddedInstance {
|
||
parent: Some(rs_id),
|
||
name: "EchoTest".to_string(),
|
||
class_name: "ModuleScript".to_string(),
|
||
properties,
|
||
children: vec![],
|
||
};
|
||
let mut added_map = HashMap::new();
|
||
added_map.insert(Ref::new(), added);
|
||
let write_request = WriteRequest {
|
||
session_id: info.session_id,
|
||
removed: vec![],
|
||
added: added_map,
|
||
updated: vec![],
|
||
};
|
||
session.post_api_write(&write_request).unwrap();
|
||
|
||
// Wait for any echo events to settle
|
||
thread::sleep(Duration::from_millis(500));
|
||
|
||
// File should exist on disk
|
||
let file_path = session.path().join("src").join("EchoTest.luau");
|
||
assert_file_exists(&file_path, "EchoTest.luau created");
|
||
|
||
// Server should still be responsive
|
||
let read_after = session.get_api_read(root_id).unwrap();
|
||
let cursor_delta = read_after.message_cursor - initial_cursor;
|
||
|
||
// With proper echo suppression, the cursor should not advance excessively
|
||
assert!(
|
||
cursor_delta < 10,
|
||
"Message cursor should not advance excessively (delta={}), \
|
||
indicating echo suppression is working",
|
||
cursor_delta
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers (syncback_format_transitions fixture)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Look up an instance by name under ReplicatedStorage in the format_transitions fixture.
|
||
fn get_format_transitions_instance(
|
||
session: &crate::rojo_test::serve_util::TestServeSession,
|
||
instance_name: &str,
|
||
) -> (librojo::SessionId, Ref) {
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let (instance_id, _) = find_by_name(&rs_read.instances, instance_name);
|
||
(info.session_id, instance_id)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests: Directory-format operations
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Test 13: Renaming a directory-format script renames the parent directory,
|
||
/// not the init file inside it. Children should move with it.
|
||
#[test]
|
||
fn rename_directory_format_script() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let (session_id, dir_module_id) =
|
||
get_format_transitions_instance(&session, "DirModuleWithChildren");
|
||
|
||
let old_dir = session.path().join("src").join("DirModuleWithChildren");
|
||
let new_dir = session.path().join("src").join("RenamedDirModule");
|
||
|
||
assert!(old_dir.is_dir(), "Old directory should exist before rename");
|
||
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: dir_module_id,
|
||
changed_name: Some("RenamedDirModule".to_string()),
|
||
changed_class_name: None,
|
||
changed_properties: UstrMap::default(),
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
poll_not_exists(&old_dir, "Old directory should be gone after rename");
|
||
|
||
// Poll for the new directory to appear
|
||
let start = Instant::now();
|
||
loop {
|
||
if new_dir.is_dir() {
|
||
break;
|
||
}
|
||
if start.elapsed() > Duration::from_millis(API_POLL_TIMEOUT_MS) {
|
||
panic!(
|
||
"New directory should exist after rename: {} (timed out after {}ms)",
|
||
new_dir.display(),
|
||
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 also be present in the renamed directory
|
||
let child_a = new_dir.join("ChildA.luau");
|
||
assert_file_exists(&child_a, "ChildA.luau inside renamed directory");
|
||
});
|
||
}
|
||
|
||
/// Test 14: Changing ClassName on a directory-format ModuleScript to Script
|
||
/// renames the init file inside the directory (init.luau → init.server.luau).
|
||
#[test]
|
||
fn classname_change_directory_module_to_script() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let (session_id, dir_module_id) =
|
||
get_format_transitions_instance(&session, "DirModuleWithChildren");
|
||
|
||
let dir = session.path().join("src").join("DirModuleWithChildren");
|
||
let old_init = dir.join("init.luau");
|
||
let new_init = dir.join("init.server.luau");
|
||
|
||
assert_file_exists(&old_init, "init.luau before class change");
|
||
assert_not_exists(&new_init, "init.server.luau before class change");
|
||
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: dir_module_id,
|
||
changed_name: None,
|
||
changed_class_name: Some(ustr("Script")),
|
||
changed_properties: UstrMap::default(),
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
poll_not_exists(&old_init, "init.luau after class change to Script");
|
||
poll_file_exists(&new_init, "init.server.luau after class change to Script");
|
||
|
||
// Children should be unaffected
|
||
let child_a = dir.join("ChildA.luau");
|
||
assert_file_exists(&child_a, "ChildA.luau unaffected by class change");
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests: Reverse ClassName transitions (code paths added in these commits)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Test 15: Script → ModuleScript ClassName transition (reverse of test 6).
|
||
/// Validates that the .server suffix is correctly stripped.
|
||
#[test]
|
||
fn classname_change_script_to_module() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let (session_id, script_id) = get_format_transitions_instance(&session, "StandaloneScript");
|
||
|
||
let old_path = session
|
||
.path()
|
||
.join("src")
|
||
.join("StandaloneScript.server.luau");
|
||
let new_path = session.path().join("src").join("StandaloneScript.luau");
|
||
|
||
assert_file_exists(&old_path, "Script file before class change");
|
||
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: script_id,
|
||
changed_name: None,
|
||
changed_class_name: Some(ustr("ModuleScript")),
|
||
changed_properties: UstrMap::default(),
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
poll_not_exists(&old_path, "Script file after class change to ModuleScript");
|
||
poll_file_exists(&new_path, "ModuleScript file after class change");
|
||
});
|
||
}
|
||
|
||
/// Test 16: Script → LocalScript ClassName transition.
|
||
/// Validates that .server is replaced with .local.
|
||
#[test]
|
||
fn classname_change_script_to_localscript() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let (session_id, script_id) = get_format_transitions_instance(&session, "StandaloneScript");
|
||
|
||
let old_path = session
|
||
.path()
|
||
.join("src")
|
||
.join("StandaloneScript.server.luau");
|
||
let new_path = session
|
||
.path()
|
||
.join("src")
|
||
.join("StandaloneScript.local.luau");
|
||
|
||
assert_file_exists(&old_path, "Script file before class change");
|
||
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: script_id,
|
||
changed_name: None,
|
||
changed_class_name: Some(ustr("LocalScript")),
|
||
changed_properties: UstrMap::default(),
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
poll_not_exists(&old_path, "Script file after class change to LocalScript");
|
||
poll_file_exists(&new_path, "LocalScript file after class change");
|
||
});
|
||
}
|
||
|
||
/// Test 17: LocalScript → Script ClassName transition.
|
||
/// Exercises .client suffix handling (the fixture uses .client.luau for LocalScript).
|
||
#[test]
|
||
fn classname_change_localscript_to_script() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let (session_id, local_id) =
|
||
get_format_transitions_instance(&session, "StandaloneLocalScript");
|
||
|
||
let old_path = session
|
||
.path()
|
||
.join("src")
|
||
.join("StandaloneLocalScript.client.luau");
|
||
let new_path = session
|
||
.path()
|
||
.join("src")
|
||
.join("StandaloneLocalScript.server.luau");
|
||
|
||
assert_file_exists(&old_path, "LocalScript file before class change");
|
||
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: local_id,
|
||
changed_name: None,
|
||
changed_class_name: Some(ustr("Script")),
|
||
changed_properties: UstrMap::default(),
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
poll_not_exists(&old_path, "LocalScript file after class change to Script");
|
||
poll_file_exists(&new_path, "Script file after class change");
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests: Combined operations (overridden_source_path logic)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Test 18: Combined rename + Source update in a single request.
|
||
/// Validates that Source is written to the RENAMED file, not the old location.
|
||
/// This exercises the `overridden_source_path` tracking in ChangeProcessor.
|
||
#[test]
|
||
fn combined_rename_and_source_update() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
|
||
let old_path = session.path().join("src").join("existing.luau");
|
||
let new_path = session.path().join("src").join("CombinedRename.luau");
|
||
|
||
assert_file_exists(&old_path, "existing.luau before combined update");
|
||
|
||
let mut props = UstrMap::default();
|
||
props.insert(
|
||
ustr("Source"),
|
||
Some(Variant::String("-- Written after rename".to_string())),
|
||
);
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: existing_id,
|
||
changed_name: Some("CombinedRename".to_string()),
|
||
changed_class_name: None,
|
||
changed_properties: props,
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
poll_not_exists(&old_path, "Old file after combined rename+source");
|
||
poll_file_contains(
|
||
&new_path,
|
||
"Written after rename",
|
||
"Source should be written to the NEW file location",
|
||
);
|
||
});
|
||
}
|
||
|
||
/// Test 19: Combined ClassName change + Source update in a single request.
|
||
/// Validates that Source is written to the file with the NEW extension.
|
||
#[test]
|
||
fn combined_classname_and_source_update() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
|
||
let old_path = session.path().join("src").join("existing.luau");
|
||
let new_path = session.path().join("src").join("existing.server.luau");
|
||
|
||
assert_file_exists(&old_path, "existing.luau before combined update");
|
||
|
||
let mut props = UstrMap::default();
|
||
props.insert(
|
||
ustr("Source"),
|
||
Some(Variant::String("-- Source after class change".to_string())),
|
||
);
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: existing_id,
|
||
changed_name: None,
|
||
changed_class_name: Some(ustr("Script")),
|
||
changed_properties: props,
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
poll_not_exists(&old_path, "ModuleScript file after ClassName+Source");
|
||
poll_file_contains(
|
||
&new_path,
|
||
"Source after class change",
|
||
"Source should be in the new file",
|
||
);
|
||
});
|
||
}
|
||
|
||
/// Test 19b: Combined rename + ClassName change in a single update.
|
||
/// The rename handler moves the file first (existing.luau → Renamed.luau),
|
||
/// then the ClassName handler must use the renamed path to apply the extension
|
||
/// change (Renamed.luau → Renamed.server.luau). Without the fix, the ClassName
|
||
/// handler checks the OLD path which no longer exists, silently skipping the
|
||
/// extension rename.
|
||
#[test]
|
||
fn combined_rename_and_classname_change() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
|
||
let old_path = session.path().join("src").join("existing.luau");
|
||
// After rename + ClassName change: should end up as Renamed.server.luau
|
||
let final_path = session.path().join("src").join("Renamed.server.luau");
|
||
// Should NOT exist: renamed but without extension change
|
||
let renamed_only = session.path().join("src").join("Renamed.luau");
|
||
|
||
assert_file_exists(&old_path, "existing.luau before combined update");
|
||
|
||
let mut props = UstrMap::default();
|
||
props.insert(
|
||
ustr("Source"),
|
||
Some(Variant::String(
|
||
"-- After rename + class change".to_string(),
|
||
)),
|
||
);
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: existing_id,
|
||
changed_name: Some("Renamed".to_string()),
|
||
changed_class_name: Some(ustr("Script")),
|
||
changed_properties: props,
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
poll_not_exists(&old_path, "Original file after combined rename+classname");
|
||
poll_not_exists(
|
||
&renamed_only,
|
||
"Renamed.luau should not exist — ClassName handler should have \
|
||
applied the .server extension",
|
||
);
|
||
poll_file_contains(
|
||
&final_path,
|
||
"After rename + class change",
|
||
"Renamed.server.luau should exist with correct Source",
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests: Standalone → directory conversion & suffix-aware removal
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Test 20: Adding a child instance to a standalone script converts it to
|
||
/// directory format (e.g., StandaloneModule.luau → StandaloneModule/init.luau).
|
||
/// This exercises `convert_standalone_script_to_directory` in ApiService.
|
||
#[test]
|
||
fn add_child_converts_standalone_to_directory() {
|
||
run_serve_test("syncback_format_transitions", |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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let (standalone_id, _) = find_by_name(&rs_read.instances, "StandaloneModule");
|
||
|
||
let standalone_file = session.path().join("src").join("StandaloneModule.luau");
|
||
let dir_path = session.path().join("src").join("StandaloneModule");
|
||
|
||
assert_file_exists(&standalone_file, "Standalone file before child add");
|
||
assert!(
|
||
!dir_path.exists(),
|
||
"Directory should NOT exist before child add"
|
||
);
|
||
|
||
// Add a child ModuleScript to StandaloneModule
|
||
let mut properties = HashMap::new();
|
||
properties.insert(
|
||
"Source".to_string(),
|
||
Variant::String("-- New child module".to_string()),
|
||
);
|
||
let added = AddedInstance {
|
||
parent: Some(standalone_id),
|
||
name: "NewChild".to_string(),
|
||
class_name: "ModuleScript".to_string(),
|
||
properties,
|
||
children: vec![],
|
||
};
|
||
let mut added_map = HashMap::new();
|
||
added_map.insert(Ref::new(), added);
|
||
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));
|
||
|
||
// Standalone file should be gone, replaced by directory
|
||
assert_not_exists(
|
||
&standalone_file,
|
||
"Standalone file after child add (converted to directory)",
|
||
);
|
||
assert!(
|
||
dir_path.is_dir(),
|
||
"Directory should exist after conversion: {}",
|
||
dir_path.display()
|
||
);
|
||
|
||
// init.luau should contain the original source
|
||
let init_file = dir_path.join("init.luau");
|
||
assert_file_exists(
|
||
&init_file,
|
||
"init.luau after standalone→directory conversion",
|
||
);
|
||
|
||
let init_content = fs::read_to_string(&init_file).unwrap();
|
||
assert!(
|
||
init_content.contains("Standalone ModuleScript"),
|
||
"init.luau should contain original source, got: {}",
|
||
init_content
|
||
);
|
||
|
||
// Child should be created inside the directory
|
||
let child_file = dir_path.join("NewChild.luau");
|
||
assert_file_exists(&child_file, "NewChild.luau inside converted directory");
|
||
});
|
||
}
|
||
|
||
/// Test 21: Removing a Script (with .server suffix) properly cleans up
|
||
/// the adjacent meta file by stripping the script suffix to find the
|
||
/// correct meta file name (e.g., StandaloneScript.meta.json5).
|
||
#[test]
|
||
fn remove_script_with_suffix_cleans_meta() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let (session_id, script_id) = get_format_transitions_instance(&session, "StandaloneScript");
|
||
|
||
let script_path = session
|
||
.path()
|
||
.join("src")
|
||
.join("StandaloneScript.server.luau");
|
||
let meta_path = session
|
||
.path()
|
||
.join("src")
|
||
.join("StandaloneScript.meta.json5");
|
||
|
||
assert_file_exists(&script_path, "Script file before operations");
|
||
|
||
// First, create a meta file by sending a non-Source property update
|
||
let mut attrs = rbx_dom_weak::types::Attributes::new();
|
||
attrs.insert("ScriptAttr".to_string(), Variant::Float64(7.0));
|
||
let mut props = UstrMap::default();
|
||
props.insert(ustr("Attributes"), Some(Variant::Attributes(attrs)));
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: script_id,
|
||
changed_name: None,
|
||
changed_class_name: None,
|
||
changed_properties: props,
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
assert_file_exists(&meta_path, "Meta file after property update");
|
||
|
||
// Re-read ID in case tree was rebuilt after the update
|
||
let (session_id, script_id) = get_format_transitions_instance(&session, "StandaloneScript");
|
||
|
||
// Now remove the instance — both script and meta should be deleted
|
||
send_removal(&session, &session_id, vec![script_id]);
|
||
|
||
assert_not_exists(&script_path, "Script file after removal");
|
||
assert_not_exists(
|
||
&meta_path,
|
||
"Meta file after removal (suffix-stripped cleanup)",
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers (syncback_encoded_names fixture)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Look up an instance by name under ReplicatedStorage in the encoded_names fixture.
|
||
/// Instance names are DECODED (e.g., `Encoded?Module`), not the filesystem names.
|
||
fn get_encoded_names_instance(
|
||
session: &crate::rojo_test::serve_util::TestServeSession,
|
||
instance_name: &str,
|
||
) -> (librojo::SessionId, Ref) {
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let (instance_id, _) = find_by_name(&rs_read.instances, instance_name);
|
||
(info.session_id, instance_id)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests: Encoded name directory conversion (path encoding fix)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Test 22: Adding a child to a standalone ModuleScript with Rojo-encoded
|
||
/// characters (e.g., `What%QUESTION%Module.luau`) creates the directory using
|
||
/// the filesystem-encoded name (`What%QUESTION%Module/`), NOT the decoded
|
||
/// instance name (`What?Module/`).
|
||
///
|
||
/// This is the core regression test for the path encoding fix in
|
||
/// `convert_standalone_script_to_directory`.
|
||
#[test]
|
||
fn add_child_to_encoded_module_creates_encoded_directory() {
|
||
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||
let (session_id, encoded_module_id) = get_encoded_names_instance(&session, "What?Module");
|
||
|
||
let standalone_file = session.path().join("src").join("What%QUESTION%Module.luau");
|
||
// The CORRECT directory name uses the encoded filesystem name
|
||
let encoded_dir = session.path().join("src").join("What%QUESTION%Module");
|
||
// The WRONG directory name would use the decoded instance name
|
||
let decoded_dir = session.path().join("src").join("What?Module");
|
||
|
||
assert_file_exists(&standalone_file, "Encoded standalone file before child add");
|
||
|
||
// Add a child to trigger standalone → directory conversion
|
||
let mut properties = HashMap::new();
|
||
properties.insert(
|
||
"Source".to_string(),
|
||
Variant::String("-- Child of encoded module".to_string()),
|
||
);
|
||
let added = AddedInstance {
|
||
parent: Some(encoded_module_id),
|
||
name: "EncodedChild".to_string(),
|
||
class_name: "ModuleScript".to_string(),
|
||
properties,
|
||
children: vec![],
|
||
};
|
||
let mut added_map = HashMap::new();
|
||
added_map.insert(Ref::new(), added);
|
||
let write_request = WriteRequest {
|
||
session_id,
|
||
removed: vec![],
|
||
added: added_map,
|
||
updated: vec![],
|
||
};
|
||
session.post_api_write(&write_request).unwrap();
|
||
thread::sleep(Duration::from_millis(500));
|
||
|
||
// Standalone file should be removed
|
||
assert_not_exists(
|
||
&standalone_file,
|
||
"Standalone file after directory conversion",
|
||
);
|
||
|
||
// Directory should use ENCODED name (the fix)
|
||
assert!(
|
||
encoded_dir.is_dir(),
|
||
"Directory should use encoded name (What%QUESTION%Module/): {}",
|
||
encoded_dir.display()
|
||
);
|
||
|
||
// Decoded name directory should NOT exist (the bug we fixed)
|
||
assert!(
|
||
!decoded_dir.exists(),
|
||
"Directory with decoded name (What?Module/) must NOT be created: {}",
|
||
decoded_dir.display()
|
||
);
|
||
|
||
// init.luau should exist inside the encoded directory
|
||
let init_file = encoded_dir.join("init.luau");
|
||
assert_file_exists(&init_file, "init.luau inside encoded directory");
|
||
|
||
// Child should be created inside the encoded directory
|
||
let child_file = encoded_dir.join("EncodedChild.luau");
|
||
assert_file_exists(&child_file, "Child inside encoded directory");
|
||
});
|
||
}
|
||
|
||
/// Test 23: Adding a child to a standalone Script with Rojo-encoded characters
|
||
/// AND a script suffix (e.g., `Key%COLON%Script.server.luau`) strips the suffix
|
||
/// correctly when creating the directory name.
|
||
///
|
||
/// The directory should be `Key%COLON%Script/`, not `Key%COLON%Script.server/`
|
||
/// or `Key:Script/`.
|
||
#[test]
|
||
fn add_child_to_encoded_script_strips_suffix_correctly() {
|
||
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||
let (session_id, encoded_script_id) = get_encoded_names_instance(&session, "Key:Script");
|
||
|
||
let standalone_file = session
|
||
.path()
|
||
.join("src")
|
||
.join("Key%COLON%Script.server.luau");
|
||
let encoded_dir = session.path().join("src").join("Key%COLON%Script");
|
||
// Wrong: decoded name
|
||
let decoded_dir = session.path().join("src").join("Key:Script");
|
||
// Wrong: suffix not stripped
|
||
let unsuffixed_dir = session.path().join("src").join("Key%COLON%Script.server");
|
||
|
||
assert_file_exists(&standalone_file, "Encoded script before child add");
|
||
|
||
let mut properties = HashMap::new();
|
||
properties.insert(
|
||
"Source".to_string(),
|
||
Variant::String("-- Child of encoded script".to_string()),
|
||
);
|
||
let added = AddedInstance {
|
||
parent: Some(encoded_script_id),
|
||
name: "ScriptChild".to_string(),
|
||
class_name: "ModuleScript".to_string(),
|
||
properties,
|
||
children: vec![],
|
||
};
|
||
let mut added_map = HashMap::new();
|
||
added_map.insert(Ref::new(), added);
|
||
let write_request = WriteRequest {
|
||
session_id,
|
||
removed: vec![],
|
||
added: added_map,
|
||
updated: vec![],
|
||
};
|
||
session.post_api_write(&write_request).unwrap();
|
||
thread::sleep(Duration::from_millis(500));
|
||
|
||
assert_not_exists(&standalone_file, "Standalone script after conversion");
|
||
|
||
assert!(
|
||
encoded_dir.is_dir(),
|
||
"Directory should be Key%COLON%Script/ (suffix stripped, encoded): {}",
|
||
encoded_dir.display()
|
||
);
|
||
assert!(
|
||
!decoded_dir.exists(),
|
||
"Directory with decoded name must NOT be created: {}",
|
||
decoded_dir.display()
|
||
);
|
||
assert!(
|
||
!unsuffixed_dir.exists(),
|
||
"Directory with unsuffixed name must NOT be created: {}",
|
||
unsuffixed_dir.display()
|
||
);
|
||
|
||
// Script → init.server.luau (preserves class)
|
||
let init_file = encoded_dir.join("init.server.luau");
|
||
assert_file_exists(&init_file, "init.server.luau inside encoded directory");
|
||
|
||
let child_file = encoded_dir.join("ScriptChild.luau");
|
||
assert_file_exists(&child_file, "Child inside encoded script directory");
|
||
});
|
||
}
|
||
|
||
/// Test 24: Adding a child to a non-script standalone instance with Rojo-encoded
|
||
/// characters (e.g., `What%QUESTION%Model.model.json5`) creates the directory
|
||
/// using the encoded name with the compound extension stripped.
|
||
///
|
||
/// This exercises `convert_standalone_instance_to_directory`.
|
||
#[test]
|
||
fn add_child_to_encoded_model_creates_encoded_directory() {
|
||
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||
let (session_id, encoded_model_id) = get_encoded_names_instance(&session, "What?Model");
|
||
|
||
let standalone_file = session
|
||
.path()
|
||
.join("src")
|
||
.join("What%QUESTION%Model.model.json5");
|
||
let encoded_dir = session.path().join("src").join("What%QUESTION%Model");
|
||
let decoded_dir = session.path().join("src").join("What?Model");
|
||
|
||
assert_file_exists(&standalone_file, "Encoded model file before child add");
|
||
|
||
let mut properties = HashMap::new();
|
||
properties.insert(
|
||
"Source".to_string(),
|
||
Variant::String("-- Child of encoded model".to_string()),
|
||
);
|
||
let added = AddedInstance {
|
||
parent: Some(encoded_model_id),
|
||
name: "ModelChild".to_string(),
|
||
class_name: "ModuleScript".to_string(),
|
||
properties,
|
||
children: vec![],
|
||
};
|
||
let mut added_map = HashMap::new();
|
||
added_map.insert(Ref::new(), added);
|
||
let write_request = WriteRequest {
|
||
session_id,
|
||
removed: vec![],
|
||
added: added_map,
|
||
updated: vec![],
|
||
};
|
||
session.post_api_write(&write_request).unwrap();
|
||
thread::sleep(Duration::from_millis(500));
|
||
|
||
assert_not_exists(&standalone_file, "Standalone model after conversion");
|
||
|
||
assert!(
|
||
encoded_dir.is_dir(),
|
||
"Directory should use encoded name (What%QUESTION%Model/): {}",
|
||
encoded_dir.display()
|
||
);
|
||
assert!(
|
||
!decoded_dir.exists(),
|
||
"Directory with decoded name must NOT be created: {}",
|
||
decoded_dir.display()
|
||
);
|
||
|
||
// init.meta.json5 should exist inside the directory (converted from .model.json5)
|
||
let init_meta = encoded_dir.join("init.meta.json5");
|
||
assert_file_exists(&init_meta, "init.meta.json5 inside encoded model directory");
|
||
|
||
let child_file = encoded_dir.join("ModelChild.luau");
|
||
assert_file_exists(&child_file, "Child inside encoded model directory");
|
||
});
|
||
}
|
||
|
||
/// Test 25: Non-Source property update on a standalone script with Rojo-encoded
|
||
/// name creates the adjacent meta file using the encoded filesystem name,
|
||
/// not the decoded instance name.
|
||
///
|
||
/// e.g., `What%QUESTION%Module.meta.json5` NOT `What?Module.meta.json5`
|
||
#[test]
|
||
fn property_update_on_encoded_script_uses_encoded_meta_path() {
|
||
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||
let (session_id, encoded_module_id) = get_encoded_names_instance(&session, "What?Module");
|
||
|
||
let script_file = session.path().join("src").join("What%QUESTION%Module.luau");
|
||
let encoded_meta = session
|
||
.path()
|
||
.join("src")
|
||
.join("What%QUESTION%Module.meta.json5");
|
||
let decoded_meta = session.path().join("src").join("What?Module.meta.json5");
|
||
|
||
assert_file_exists(&script_file, "Encoded script file before property update");
|
||
assert_not_exists(&encoded_meta, "Meta file before property update");
|
||
|
||
let mut attrs = rbx_dom_weak::types::Attributes::new();
|
||
attrs.insert("EncodedAttr".to_string(), Variant::Float64(99.0));
|
||
|
||
let mut props = UstrMap::default();
|
||
props.insert(ustr("Attributes"), Some(Variant::Attributes(attrs)));
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
InstanceUpdate {
|
||
id: encoded_module_id,
|
||
changed_name: None,
|
||
changed_class_name: None,
|
||
changed_properties: props,
|
||
changed_metadata: None,
|
||
},
|
||
);
|
||
|
||
// Meta file should use encoded name
|
||
assert_file_exists(
|
||
&encoded_meta,
|
||
"Meta file should use encoded name (What%QUESTION%Module.meta.json5)",
|
||
);
|
||
assert!(
|
||
!decoded_meta.exists(),
|
||
"Meta file with decoded name must NOT be created: {}",
|
||
decoded_meta.display()
|
||
);
|
||
|
||
// Script file should be unchanged
|
||
let content = fs::read_to_string(&script_file).unwrap();
|
||
assert!(
|
||
content.contains("Module with encoded"),
|
||
"Script content should be unchanged, got: {}",
|
||
content
|
||
);
|
||
});
|
||
}
|
||
|
||
// ===========================================================================
|
||
// PART 1: API-Driven Stress Tests (Tests 26-41)
|
||
// ===========================================================================
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stress helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Send an InstanceUpdate with a short 50ms wait (races the 200ms recovery).
|
||
fn send_update_fast(
|
||
session: &crate::rojo_test::serve_util::TestServeSession,
|
||
session_id: &librojo::SessionId,
|
||
update: InstanceUpdate,
|
||
) {
|
||
let write_request = WriteRequest {
|
||
session_id: *session_id,
|
||
removed: vec![],
|
||
added: HashMap::new(),
|
||
updated: vec![update],
|
||
};
|
||
session
|
||
.post_api_write(&write_request)
|
||
.expect("Write request should succeed");
|
||
thread::sleep(Duration::from_millis(50));
|
||
}
|
||
|
||
/// Send an InstanceUpdate with zero wait (fire and forget).
|
||
fn send_update_no_wait(
|
||
session: &crate::rojo_test::serve_util::TestServeSession,
|
||
session_id: &librojo::SessionId,
|
||
update: InstanceUpdate,
|
||
) {
|
||
let write_request = WriteRequest {
|
||
session_id: *session_id,
|
||
removed: vec![],
|
||
added: HashMap::new(),
|
||
updated: vec![update],
|
||
};
|
||
session
|
||
.post_api_write(&write_request)
|
||
.expect("Write request should succeed");
|
||
}
|
||
|
||
/// Send a removal with a short 50ms wait.
|
||
fn send_removal_fast(
|
||
session: &crate::rojo_test::serve_util::TestServeSession,
|
||
session_id: &librojo::SessionId,
|
||
ids: Vec<Ref>,
|
||
) {
|
||
let write_request = WriteRequest {
|
||
session_id: *session_id,
|
||
removed: ids,
|
||
added: HashMap::new(),
|
||
updated: vec![],
|
||
};
|
||
session
|
||
.post_api_write(&write_request)
|
||
.expect("Write request should succeed");
|
||
thread::sleep(Duration::from_millis(50));
|
||
}
|
||
|
||
/// Wait long enough for all VFS events, recovery sweeps, and debouncing.
|
||
fn wait_for_settle() {
|
||
thread::sleep(Duration::from_millis(800));
|
||
}
|
||
|
||
/// Get session_id and Refs for Alpha-Echo in the syncback_stress fixture.
|
||
fn get_stress_instances(
|
||
session: &crate::rojo_test::serve_util::TestServeSession,
|
||
) -> (librojo::SessionId, Ref, Vec<(Ref, String)>) {
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let names = ["Alpha", "Bravo", "Charlie", "Delta", "Echo"];
|
||
let mut instances = Vec::new();
|
||
for name in &names {
|
||
let (id, _) = find_by_name(&rs_read.instances, name);
|
||
instances.push((id, name.to_string()));
|
||
}
|
||
(info.session_id, rs_id, instances)
|
||
}
|
||
|
||
/// Build a Source update for an instance.
|
||
fn make_source_update(id: Ref, source: &str) -> InstanceUpdate {
|
||
let mut props = UstrMap::default();
|
||
props.insert(ustr("Source"), Some(Variant::String(source.to_string())));
|
||
InstanceUpdate {
|
||
id,
|
||
changed_name: None,
|
||
changed_class_name: None,
|
||
changed_properties: props,
|
||
changed_metadata: None,
|
||
}
|
||
}
|
||
|
||
/// Build a rename update.
|
||
fn make_rename_update(id: Ref, new_name: &str) -> InstanceUpdate {
|
||
InstanceUpdate {
|
||
id,
|
||
changed_name: Some(new_name.to_string()),
|
||
changed_class_name: None,
|
||
changed_properties: UstrMap::default(),
|
||
changed_metadata: None,
|
||
}
|
||
}
|
||
|
||
/// Build a ClassName update.
|
||
fn make_class_update(id: Ref, class: &str) -> InstanceUpdate {
|
||
InstanceUpdate {
|
||
id,
|
||
changed_name: None,
|
||
changed_class_name: Some(ustr(class)),
|
||
changed_properties: UstrMap::default(),
|
||
changed_metadata: None,
|
||
}
|
||
}
|
||
|
||
/// Build a combined rename + class + source update.
|
||
fn make_combined_update(
|
||
id: Ref,
|
||
name: Option<&str>,
|
||
class: Option<&str>,
|
||
source: Option<&str>,
|
||
) -> InstanceUpdate {
|
||
let mut props = UstrMap::default();
|
||
if let Some(src) = source {
|
||
props.insert(ustr("Source"), Some(Variant::String(src.to_string())));
|
||
}
|
||
InstanceUpdate {
|
||
id,
|
||
changed_name: name.map(|n| n.to_string()),
|
||
changed_class_name: class.map(ustr),
|
||
changed_properties: props,
|
||
changed_metadata: None,
|
||
}
|
||
}
|
||
|
||
/// Get the expected file extension suffix for a class name.
|
||
fn class_suffix(class: &str) -> &'static str {
|
||
match class {
|
||
"Script" => ".server",
|
||
"LocalScript" => ".local",
|
||
_ => "",
|
||
}
|
||
}
|
||
|
||
/// Verify that exactly one file exists for the given name+class, and no stale
|
||
/// files remain with other extensions.
|
||
fn verify_instance_file(src_dir: &Path, name: &str, class: &str, expected_source: Option<&str>) {
|
||
let suffix = class_suffix(class);
|
||
let expected_file = src_dir.join(format!("{}{}.luau", name, suffix));
|
||
assert!(
|
||
expected_file.is_file(),
|
||
"Expected file: {}",
|
||
expected_file.display()
|
||
);
|
||
if let Some(source) = expected_source {
|
||
let content = fs::read_to_string(&expected_file).unwrap();
|
||
assert!(
|
||
content.contains(source),
|
||
"Expected source '{}' in file {}, got: {}",
|
||
source,
|
||
expected_file.display(),
|
||
content
|
||
);
|
||
}
|
||
// Check no stale files with wrong extension
|
||
for stale_suffix in &[".server", ".local", ".client", ".plugin", ""] {
|
||
if *stale_suffix == suffix {
|
||
continue;
|
||
}
|
||
let stale = src_dir.join(format!("{}{}.luau", name, stale_suffix));
|
||
assert!(
|
||
!stale.exists(),
|
||
"Stale file should not exist: {}",
|
||
stale.display()
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tree polling helpers (for file-watcher tests)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Get ReplicatedStorage Ref.
|
||
fn get_rs_id(session: &crate::rojo_test::serve_util::TestServeSession) -> Ref {
|
||
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");
|
||
rs_id
|
||
}
|
||
|
||
/// Poll tree until an instance named `name` under `parent_id` has Source
|
||
/// containing `expected`. Panics after `timeout_ms`.
|
||
fn poll_tree_source(
|
||
session: &crate::rojo_test::serve_util::TestServeSession,
|
||
parent_id: Ref,
|
||
instance_name: &str,
|
||
expected_content: &str,
|
||
timeout_ms: u64,
|
||
) {
|
||
let start = Instant::now();
|
||
loop {
|
||
if let Ok(read) = session.get_api_read(parent_id) {
|
||
for inst in read.instances.values() {
|
||
if inst.name == instance_name {
|
||
if let Some(source) = inst.properties.get(&ustr("Source")) {
|
||
if let Variant::String(s) = source.as_ref() {
|
||
if s.contains(expected_content) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if start.elapsed() > Duration::from_millis(timeout_ms) {
|
||
panic!(
|
||
"Timed out after {}ms waiting for instance '{}' Source to contain '{}'",
|
||
timeout_ms, instance_name, expected_content
|
||
);
|
||
}
|
||
thread::sleep(Duration::from_millis(50));
|
||
}
|
||
}
|
||
|
||
/// Poll tree until an instance named `name` exists under `parent_id`.
|
||
fn poll_tree_has_instance(
|
||
session: &crate::rojo_test::serve_util::TestServeSession,
|
||
parent_id: Ref,
|
||
instance_name: &str,
|
||
timeout_ms: u64,
|
||
) -> Ref {
|
||
let start = Instant::now();
|
||
loop {
|
||
if let Ok(read) = session.get_api_read(parent_id) {
|
||
for (&id, inst) in &read.instances {
|
||
if inst.name == instance_name {
|
||
return id;
|
||
}
|
||
}
|
||
}
|
||
if start.elapsed() > Duration::from_millis(timeout_ms) {
|
||
panic!(
|
||
"Timed out after {}ms waiting for instance '{}' to appear",
|
||
timeout_ms, instance_name
|
||
);
|
||
}
|
||
thread::sleep(Duration::from_millis(50));
|
||
}
|
||
}
|
||
|
||
/// Poll tree until NO instance named `name` exists under `parent_id`.
|
||
fn poll_tree_no_instance(
|
||
session: &crate::rojo_test::serve_util::TestServeSession,
|
||
parent_id: Ref,
|
||
instance_name: &str,
|
||
timeout_ms: u64,
|
||
) {
|
||
let start = Instant::now();
|
||
loop {
|
||
if let Ok(read) = session.get_api_read(parent_id) {
|
||
let found = read
|
||
.instances
|
||
.values()
|
||
.any(|inst| inst.name == instance_name);
|
||
if !found {
|
||
return;
|
||
}
|
||
}
|
||
if start.elapsed() > Duration::from_millis(timeout_ms) {
|
||
panic!(
|
||
"Timed out after {}ms waiting for instance '{}' to disappear",
|
||
timeout_ms, instance_name
|
||
);
|
||
}
|
||
thread::sleep(Duration::from_millis(50));
|
||
}
|
||
}
|
||
|
||
/// Poll tree until instance `name` has the expected ClassName.
|
||
fn poll_tree_class(
|
||
session: &crate::rojo_test::serve_util::TestServeSession,
|
||
parent_id: Ref,
|
||
instance_name: &str,
|
||
expected_class: &str,
|
||
timeout_ms: u64,
|
||
) {
|
||
let start = Instant::now();
|
||
loop {
|
||
if let Ok(read) = session.get_api_read(parent_id) {
|
||
for inst in read.instances.values() {
|
||
if inst.name == instance_name && inst.class_name == expected_class {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
if start.elapsed() > Duration::from_millis(timeout_ms) {
|
||
panic!(
|
||
"Timed out after {}ms waiting for instance '{}' to have class '{}'",
|
||
timeout_ms, instance_name, expected_class
|
||
);
|
||
}
|
||
thread::sleep(Duration::from_millis(50));
|
||
}
|
||
}
|
||
|
||
/// Poll tree until instance `name` under `parent_id` has at least one child
|
||
/// with the given `child_name`. Returns the child's Ref.
|
||
fn poll_tree_has_child(
|
||
session: &crate::rojo_test::serve_util::TestServeSession,
|
||
parent_id: Ref,
|
||
instance_name: &str,
|
||
child_name: &str,
|
||
timeout_ms: u64,
|
||
) -> Ref {
|
||
let start = Instant::now();
|
||
loop {
|
||
if let Ok(read) = session.get_api_read(parent_id) {
|
||
for (&id, inst) in &read.instances {
|
||
if inst.name == instance_name {
|
||
// Now read this instance's children
|
||
if let Ok(child_read) = session.get_api_read(id) {
|
||
for (&cid, cinst) in &child_read.instances {
|
||
if cinst.name == child_name {
|
||
return cid;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if start.elapsed() > Duration::from_millis(timeout_ms) {
|
||
panic!(
|
||
"Timed out after {}ms waiting for '{}'.'{}'",
|
||
timeout_ms, instance_name, child_name
|
||
);
|
||
}
|
||
thread::sleep(Duration::from_millis(50));
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests 26-27: Rapid Source writes
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn rapid_source_writes_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
let file_path = session.path().join("src").join("existing.luau");
|
||
|
||
for i in 1..=10 {
|
||
send_update_fast(
|
||
&session,
|
||
&session_id,
|
||
make_source_update(existing_id, &format!("-- rapid v{}", i)),
|
||
);
|
||
}
|
||
|
||
wait_for_settle();
|
||
let content = fs::read_to_string(&file_path).unwrap();
|
||
assert!(
|
||
content.contains("-- rapid v10"),
|
||
"Final source should be v10, got: {}",
|
||
content
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn rapid_source_writes_no_wait_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
let file_path = session.path().join("src").join("existing.luau");
|
||
|
||
for i in 1..=10 {
|
||
send_update_no_wait(
|
||
&session,
|
||
&session_id,
|
||
make_source_update(existing_id, &format!("-- nowait v{}", i)),
|
||
);
|
||
}
|
||
|
||
wait_for_settle();
|
||
let content = fs::read_to_string(&file_path).unwrap();
|
||
assert!(
|
||
content.contains("-- nowait v10"),
|
||
"Final source should be v10, got: {}",
|
||
content
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests 28-29: Rapid rename chain
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn rapid_rename_chain_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let original_content = fs::read_to_string(src.join("existing.luau")).unwrap();
|
||
|
||
let mut current_name = "existing".to_string();
|
||
for i in 1..=10 {
|
||
let new_name = format!("R{}", i);
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let (id, _) = find_by_name(&rs_read.instances, ¤t_name);
|
||
send_update_fast(
|
||
&session,
|
||
&info.session_id,
|
||
make_rename_update(id, &new_name),
|
||
);
|
||
current_name = new_name;
|
||
}
|
||
|
||
wait_for_settle();
|
||
|
||
// Only R10.luau should exist
|
||
assert_file_exists(&src.join("R10.luau"), "R10.luau after chain");
|
||
for i in 1..10 {
|
||
assert_not_exists(
|
||
&src.join(format!("R{}.luau", i)),
|
||
&format!("Intermediate R{}.luau", i),
|
||
);
|
||
}
|
||
assert_not_exists(&src.join("existing.luau"), "Original file");
|
||
|
||
let content = fs::read_to_string(src.join("R10.luau")).unwrap();
|
||
assert_eq!(
|
||
original_content, content,
|
||
"Content preserved through 10 renames"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn rapid_rename_chain_directory_10x() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let mut current_name = "DirModuleWithChildren".to_string();
|
||
|
||
for i in 1..=10 {
|
||
let new_name = format!("DirR{}", i);
|
||
let (session_id, id) = get_format_transitions_instance(&session, ¤t_name);
|
||
send_update_fast(&session, &session_id, make_rename_update(id, &new_name));
|
||
current_name = new_name;
|
||
}
|
||
|
||
wait_for_settle();
|
||
|
||
let final_dir = src.join("DirR10");
|
||
assert!(final_dir.is_dir(), "DirR10 should exist");
|
||
assert_file_exists(&final_dir.join("init.luau"), "init.luau in DirR10");
|
||
assert_file_exists(&final_dir.join("ChildA.luau"), "ChildA in DirR10");
|
||
assert_file_exists(&final_dir.join("ChildB.luau"), "ChildB in DirR10");
|
||
|
||
// All intermediates should be gone
|
||
assert_not_exists(&src.join("DirModuleWithChildren"), "Original directory");
|
||
for i in 1..10 {
|
||
assert!(
|
||
!src.join(format!("DirR{}", i)).exists(),
|
||
"Intermediate DirR{} should not exist",
|
||
i
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests 30-31: Rapid ClassName cycling
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn rapid_classname_cycle_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let classes = [
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
];
|
||
|
||
for class in &classes {
|
||
let (session_id, _rs_id, id) = get_rs_and_existing(&session);
|
||
send_update_fast(&session, &session_id, make_class_update(id, class));
|
||
thread::sleep(Duration::from_millis(200)); // Let tree rebuild
|
||
}
|
||
|
||
wait_for_settle();
|
||
|
||
// Final class is Script -> existing.server.luau
|
||
verify_instance_file(&src, "existing", "Script", None);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn rapid_classname_cycle_directory_init_10x() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let dir = src.join("DirModuleWithChildren");
|
||
let classes = [
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
];
|
||
|
||
for class in &classes {
|
||
let (session_id, id) =
|
||
get_format_transitions_instance(&session, "DirModuleWithChildren");
|
||
send_update_fast(&session, &session_id, make_class_update(id, class));
|
||
thread::sleep(Duration::from_millis(200));
|
||
}
|
||
|
||
wait_for_settle();
|
||
|
||
// Final class is Script -> init.server.luau
|
||
assert_file_exists(
|
||
&dir.join("init.server.luau"),
|
||
"init.server.luau after 10 cycles",
|
||
);
|
||
// Children survive
|
||
assert_file_exists(&dir.join("ChildA.luau"), "ChildA after 10 cycles");
|
||
assert_file_exists(&dir.join("ChildB.luau"), "ChildB after 10 cycles");
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests 32-34: Combined operations blitz
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// macOS kqueue generates far more events per rename than Windows/inotify,
|
||
// overwhelming the single-threaded ChangeProcessor during rapid-fire operations.
|
||
#[test]
|
||
#[cfg_attr(target_os = "macos", ignore)]
|
||
fn combined_rename_classname_source_blitz_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rounds: Vec<(&str, &str, &str)> = vec![
|
||
("Blitz1", "Script", "-- blitz v1"),
|
||
("Blitz2", "LocalScript", "-- blitz v2"),
|
||
("Blitz3", "ModuleScript", "-- blitz v3"),
|
||
("Blitz4", "Script", "-- blitz v4"),
|
||
("Blitz5", "LocalScript", "-- blitz v5"),
|
||
("Blitz6", "ModuleScript", "-- blitz v6"),
|
||
("Blitz7", "Script", "-- blitz v7"),
|
||
("Blitz8", "LocalScript", "-- blitz v8"),
|
||
("Blitz9", "ModuleScript", "-- blitz v9"),
|
||
("Blitz10", "Script", "-- blitz v10"),
|
||
];
|
||
|
||
let mut current_name = "existing".to_string();
|
||
for (name, class, source) in &rounds {
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let (id, _) = find_by_name(&rs_read.instances, ¤t_name);
|
||
send_update_fast(
|
||
&session,
|
||
&info.session_id,
|
||
make_combined_update(id, Some(name), Some(class), Some(source)),
|
||
);
|
||
current_name = name.to_string();
|
||
thread::sleep(Duration::from_millis(STRESS_OP_DELAY_MS));
|
||
}
|
||
|
||
wait_for_settle();
|
||
// Extra settle time on macOS for kqueue event drain
|
||
#[cfg(target_os = "macos")]
|
||
thread::sleep(Duration::from_millis(2000));
|
||
|
||
// Final: Blitz10.server.luau with "-- blitz v10"
|
||
verify_instance_file(&src, "Blitz10", "Script", Some("-- blitz v10"));
|
||
|
||
// All intermediates gone
|
||
for i in 1..10 {
|
||
let name = format!("Blitz{}", i);
|
||
for suffix in &[".server", ".local", ""] {
|
||
assert_not_exists(
|
||
&src.join(format!("{}{}.luau", name, suffix)),
|
||
&format!("Intermediate {}{}.luau", name, suffix),
|
||
);
|
||
}
|
||
}
|
||
assert_not_exists(&src.join("existing.luau"), "Original file");
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn combined_rename_and_source_rapid_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let mut current_name = "existing".to_string();
|
||
|
||
for i in 1..=10 {
|
||
let new_name = format!("RS{}", i);
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let (id, _) = find_by_name(&rs_read.instances, ¤t_name);
|
||
send_update_fast(
|
||
&session,
|
||
&info.session_id,
|
||
make_combined_update(id, Some(&new_name), None, Some(&format!("-- rs v{}", i))),
|
||
);
|
||
current_name = new_name;
|
||
}
|
||
|
||
wait_for_settle();
|
||
verify_instance_file(&src, "RS10", "ModuleScript", Some("-- rs v10"));
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn combined_classname_and_source_rapid_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let classes = [
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
];
|
||
|
||
for (i, class) in classes.iter().enumerate() {
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
send_update_fast(
|
||
&session,
|
||
&session_id,
|
||
make_combined_update(
|
||
existing_id,
|
||
None,
|
||
Some(class),
|
||
Some(&format!("-- cs v{}", i + 1)),
|
||
),
|
||
);
|
||
thread::sleep(Duration::from_millis(200));
|
||
}
|
||
|
||
wait_for_settle();
|
||
verify_instance_file(&src, "existing", "Script", Some("-- cs v10"));
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests 35-36: Multi-instance concurrent
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn multi_instance_source_update_single_request() {
|
||
run_serve_test("syncback_stress", |session, _redactions| {
|
||
let (session_id, _rs_id, instances) = get_stress_instances(&session);
|
||
let src = session.path().join("src");
|
||
|
||
let updates: Vec<InstanceUpdate> = instances
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, (id, _name))| make_source_update(*id, &format!("-- multi source v{}", i + 1)))
|
||
.collect();
|
||
|
||
let write_request = WriteRequest {
|
||
session_id,
|
||
removed: vec![],
|
||
added: HashMap::new(),
|
||
updated: updates,
|
||
};
|
||
session.post_api_write(&write_request).unwrap();
|
||
wait_for_settle();
|
||
|
||
let names = ["Alpha", "Bravo", "Charlie", "Delta", "Echo"];
|
||
for (i, name) in names.iter().enumerate() {
|
||
let file = src.join(format!("{}.luau", name));
|
||
let content = fs::read_to_string(&file).unwrap();
|
||
assert!(
|
||
content.contains(&format!("-- multi source v{}", i + 1)),
|
||
"{}.luau should contain expected source, got: {}",
|
||
name,
|
||
content
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn multi_instance_rename_single_request() {
|
||
run_serve_test("syncback_stress", |session, _redactions| {
|
||
let (session_id, _rs_id, instances) = get_stress_instances(&session);
|
||
let src = session.path().join("src");
|
||
|
||
let new_names = ["AlphaR", "BravoR", "CharlieR", "DeltaR", "EchoR"];
|
||
let updates: Vec<InstanceUpdate> = instances
|
||
.iter()
|
||
.zip(new_names.iter())
|
||
.map(|((id, _), new_name)| make_rename_update(*id, new_name))
|
||
.collect();
|
||
|
||
let write_request = WriteRequest {
|
||
session_id,
|
||
removed: vec![],
|
||
added: HashMap::new(),
|
||
updated: updates,
|
||
};
|
||
session.post_api_write(&write_request).unwrap();
|
||
wait_for_settle();
|
||
|
||
for new_name in &new_names {
|
||
assert_file_exists(
|
||
&src.join(format!("{}.luau", new_name)),
|
||
&format!("{}.luau after rename", new_name),
|
||
);
|
||
}
|
||
for old_name in &["Alpha", "Bravo", "Charlie", "Delta", "Echo"] {
|
||
assert_not_exists(
|
||
&src.join(format!("{}.luau", old_name)),
|
||
&format!("Old {}.luau", old_name),
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests 37-38: Delete + recreate race
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn delete_and_recreate_via_filesystem_recovery() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
let file_path = session.path().join("src").join("existing.luau");
|
||
|
||
// Delete via API
|
||
send_removal_fast(&session, &session_id, vec![existing_id]);
|
||
|
||
// Immediately recreate the file on disk (simulating editor undo)
|
||
fs::write(&file_path, "-- recovered content\nreturn {}").unwrap();
|
||
|
||
// Wait for the recovery mechanism (200ms delay + 500ms sweep + buffer)
|
||
thread::sleep(Duration::from_millis(1500));
|
||
|
||
// Instance should be back in the tree
|
||
let rs_id = get_rs_id(&session);
|
||
poll_tree_has_instance(&session, rs_id, "existing", 3000);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn rapid_delete_recreate_cycle_5x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let file_path = session.path().join("src").join("existing.luau");
|
||
|
||
for cycle in 1..=5 {
|
||
// Re-read tree to get current instance
|
||
let (session_id, _rs_id, existing_id) = get_rs_and_existing(&session);
|
||
|
||
// Delete via API
|
||
send_removal_fast(&session, &session_id, vec![existing_id]);
|
||
|
||
// Recreate on disk
|
||
let content = format!("-- cycle {} recovered\nreturn {{}}", cycle);
|
||
fs::write(&file_path, &content).unwrap();
|
||
|
||
// Wait for recovery
|
||
thread::sleep(Duration::from_millis(1500));
|
||
|
||
// Verify instance is back
|
||
let rs_id = get_rs_id(&session);
|
||
poll_tree_has_instance(&session, rs_id, "existing", 3000);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests 39-40: Echo suppression under load
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn echo_suppression_rapid_adds_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let info = session.get_api_rojo().unwrap();
|
||
let root_id = info.root_instance_id;
|
||
let root_read = session.get_api_read(root_id).unwrap();
|
||
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
|
||
let initial_cursor = root_read.message_cursor;
|
||
|
||
for i in 0..10 {
|
||
let mut properties = HashMap::new();
|
||
properties.insert(
|
||
"Source".to_string(),
|
||
Variant::String(format!("-- echo add {}", i)),
|
||
);
|
||
let added = AddedInstance {
|
||
parent: Some(rs_id),
|
||
name: format!("EchoAdd{}", i),
|
||
class_name: "ModuleScript".to_string(),
|
||
properties,
|
||
children: vec![],
|
||
};
|
||
let mut added_map = HashMap::new();
|
||
added_map.insert(Ref::new(), added);
|
||
let write_request = WriteRequest {
|
||
session_id: info.session_id,
|
||
removed: vec![],
|
||
added: added_map,
|
||
updated: vec![],
|
||
};
|
||
session.post_api_write(&write_request).unwrap();
|
||
}
|
||
|
||
wait_for_settle();
|
||
|
||
// All 10 files should exist
|
||
let src = session.path().join("src");
|
||
for i in 0..10 {
|
||
assert_file_exists(
|
||
&src.join(format!("EchoAdd{}.luau", i)),
|
||
&format!("EchoAdd{}.luau", i),
|
||
);
|
||
}
|
||
|
||
// Server should still be responsive
|
||
let read_after = session.get_api_read(root_id).unwrap();
|
||
let cursor_delta = read_after.message_cursor - initial_cursor;
|
||
assert!(
|
||
cursor_delta < 50,
|
||
"Cursor delta {} should be reasonable with echo suppression",
|
||
cursor_delta
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn echo_suppression_mixed_operations() {
|
||
run_serve_test("syncback_stress", |session, _redactions| {
|
||
let (session_id, rs_id, instances) = get_stress_instances(&session);
|
||
let src = session.path().join("src");
|
||
|
||
// 2 adds
|
||
let mut added_map = HashMap::new();
|
||
for i in 0..2 {
|
||
let mut properties = HashMap::new();
|
||
properties.insert(
|
||
"Source".to_string(),
|
||
Variant::String(format!("-- mixed add {}", i)),
|
||
);
|
||
added_map.insert(
|
||
Ref::new(),
|
||
AddedInstance {
|
||
parent: Some(rs_id),
|
||
name: format!("MixedAdd{}", i),
|
||
class_name: "ModuleScript".to_string(),
|
||
properties,
|
||
children: vec![],
|
||
},
|
||
);
|
||
}
|
||
|
||
// 2 updates (Alpha and Bravo)
|
||
let updates: Vec<InstanceUpdate> = instances
|
||
.iter()
|
||
.take(2)
|
||
.enumerate()
|
||
.map(|(i, (id, _))| make_source_update(*id, &format!("-- mixed update {}", i)))
|
||
.collect();
|
||
|
||
// 1 removal (Echo)
|
||
let removed = vec![instances[4].0];
|
||
|
||
let write_request = WriteRequest {
|
||
session_id,
|
||
removed,
|
||
added: added_map,
|
||
updated: updates,
|
||
};
|
||
session.post_api_write(&write_request).unwrap();
|
||
wait_for_settle();
|
||
|
||
// Verify adds
|
||
for i in 0..2 {
|
||
assert_file_exists(
|
||
&src.join(format!("MixedAdd{}.luau", i)),
|
||
&format!("MixedAdd{}.luau", i),
|
||
);
|
||
}
|
||
|
||
// Verify updates
|
||
let alpha_content = fs::read_to_string(src.join("Alpha.luau")).unwrap();
|
||
assert!(
|
||
alpha_content.contains("-- mixed update 0"),
|
||
"Alpha should be updated"
|
||
);
|
||
|
||
// Verify removal
|
||
assert_not_exists(&src.join("Echo.luau"), "Echo.luau after removal");
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 41: Encoded name rapid rename chain
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn encoded_name_rapid_rename_chain() {
|
||
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let (_session_id, _module_id) = get_encoded_names_instance(&session, "What?Module");
|
||
|
||
// Rename through names with special characters
|
||
let names = ["Foo?Bar", "Key:Value", "Test?End", "Final:Name", "Done?Now"];
|
||
let mut current_name = "What?Module".to_string();
|
||
|
||
for new_name in &names {
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let (id, _) = find_by_name(&rs_read.instances, ¤t_name);
|
||
send_update_fast(&session, &info.session_id, make_rename_update(id, new_name));
|
||
current_name = new_name.to_string();
|
||
}
|
||
|
||
wait_for_settle();
|
||
|
||
// Final file should use encoded name: Done%QUESTION%Now.luau
|
||
let final_file = src.join("Done%QUESTION%Now.luau");
|
||
assert_file_exists(&final_file, "Final encoded file");
|
||
|
||
// Original should be gone
|
||
assert_not_exists(
|
||
&src.join("What%QUESTION%Module.luau"),
|
||
"Original encoded file",
|
||
);
|
||
});
|
||
}
|
||
|
||
// ===========================================================================
|
||
// PART 2: Randomized Fuzzing (Tests 42-46)
|
||
// ===========================================================================
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PRNG and fuzzing infrastructure
|
||
// ---------------------------------------------------------------------------
|
||
|
||
struct XorShift64(u64);
|
||
|
||
impl XorShift64 {
|
||
fn new(seed: u64) -> Self {
|
||
Self(if seed == 0 { 1 } else { seed })
|
||
}
|
||
|
||
fn next(&mut self) -> u64 {
|
||
self.0 ^= self.0 << 13;
|
||
self.0 ^= self.0 >> 7;
|
||
self.0 ^= self.0 << 17;
|
||
self.0
|
||
}
|
||
|
||
fn range(&mut self, min: u64, max: u64) -> u64 {
|
||
min + (self.next() % (max - min))
|
||
}
|
||
|
||
fn choose<'a, T>(&mut self, items: &'a [T]) -> &'a T {
|
||
&items[self.next() as usize % items.len()]
|
||
}
|
||
}
|
||
|
||
/// State tracker for fuzzing — knows expected file name, class, and content.
|
||
struct FuzzState {
|
||
current_name: String,
|
||
current_class: String,
|
||
current_source: String,
|
||
}
|
||
|
||
impl FuzzState {
|
||
fn new(name: &str, class: &str, source: &str) -> Self {
|
||
Self {
|
||
current_name: name.to_string(),
|
||
current_class: class.to_string(),
|
||
current_source: source.to_string(),
|
||
}
|
||
}
|
||
|
||
fn expected_file(&self) -> String {
|
||
let suffix = class_suffix(&self.current_class);
|
||
format!("{}{}.luau", self.current_name, suffix)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 42: Fuzz source and rename
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn fuzz_source_and_rename_20_iterations() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let mut rng = XorShift64::new(42);
|
||
let mut state = FuzzState::new(
|
||
"existing",
|
||
"ModuleScript",
|
||
"-- Existing module for testing\nreturn {}",
|
||
);
|
||
|
||
// Random operations in a single continuous chain (no reset).
|
||
// Reduced on macOS where kqueue's verbose events slow processing.
|
||
#[cfg(target_os = "macos")]
|
||
let iterations = 8;
|
||
#[cfg(not(target_os = "macos"))]
|
||
let iterations = 20;
|
||
|
||
for op_idx in 0..iterations {
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let found = rs_read
|
||
.instances
|
||
.iter()
|
||
.find(|(_, inst)| inst.name == state.current_name);
|
||
let inst_id = match found {
|
||
Some((&id, _)) => id,
|
||
None => {
|
||
thread::sleep(Duration::from_millis(500));
|
||
continue;
|
||
}
|
||
};
|
||
|
||
if rng.next().is_multiple_of(2) {
|
||
let new_name = format!("Fz{}", op_idx);
|
||
send_update(
|
||
&session,
|
||
&info.session_id,
|
||
make_rename_update(inst_id, &new_name),
|
||
);
|
||
state.current_name = new_name;
|
||
} else {
|
||
let new_source = format!("-- fuzz o{}", op_idx);
|
||
send_update(
|
||
&session,
|
||
&info.session_id,
|
||
make_source_update(inst_id, &new_source),
|
||
);
|
||
state.current_source = new_source;
|
||
}
|
||
}
|
||
|
||
wait_for_settle();
|
||
let expected = src.join(state.expected_file());
|
||
assert!(
|
||
expected.is_file(),
|
||
"Expected file {} to exist after {} fuzz ops",
|
||
expected.display(),
|
||
iterations
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 43: Fuzz ClassName cycling
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn fuzz_classname_cycling_20_iterations() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let class_options = ["ModuleScript", "Script", "LocalScript"];
|
||
let mut rng = XorShift64::new(77);
|
||
let mut state = FuzzState::new(
|
||
"existing",
|
||
"ModuleScript",
|
||
"-- Existing module for testing\nreturn {}",
|
||
);
|
||
|
||
// 20 random ClassName changes in a single continuous chain
|
||
for _ in 0..20 {
|
||
let (session_id, _rs_id, id) = get_rs_and_existing(&session);
|
||
let new_class = *rng.choose(&class_options);
|
||
send_update(&session, &session_id, make_class_update(id, new_class));
|
||
state.current_class = new_class.to_string();
|
||
}
|
||
|
||
wait_for_settle();
|
||
let expected = src.join(state.expected_file());
|
||
assert!(
|
||
expected.is_file(),
|
||
"Expected file {} to exist after 20 class changes",
|
||
expected.display()
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 44: Fuzz combined operations
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn fuzz_combined_operations_20_iterations() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let class_options = ["ModuleScript", "Script", "LocalScript"];
|
||
let mut rng = XorShift64::new(99);
|
||
let mut state = FuzzState::new(
|
||
"existing",
|
||
"ModuleScript",
|
||
"-- Existing module for testing\nreturn {}",
|
||
);
|
||
|
||
// Random combined operations in a single continuous chain.
|
||
// Reduced on macOS where kqueue's verbose events slow processing.
|
||
#[cfg(target_os = "macos")]
|
||
let iterations = 8;
|
||
#[cfg(not(target_os = "macos"))]
|
||
let iterations = 20;
|
||
|
||
for op_idx in 0..iterations {
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let found = rs_read
|
||
.instances
|
||
.iter()
|
||
.find(|(_, inst)| inst.name == state.current_name);
|
||
let inst_id = match found {
|
||
Some((&id, _)) => id,
|
||
None => {
|
||
thread::sleep(Duration::from_millis(500));
|
||
continue;
|
||
}
|
||
};
|
||
|
||
let op_type = rng.range(0, 4);
|
||
let new_name = format!("CF{}", op_idx);
|
||
let new_source = format!("-- cf o{}", op_idx);
|
||
let new_class = *rng.choose(&class_options);
|
||
|
||
match op_type {
|
||
0 => {
|
||
send_update(
|
||
&session,
|
||
&info.session_id,
|
||
make_source_update(inst_id, &new_source),
|
||
);
|
||
state.current_source = new_source;
|
||
}
|
||
1 => {
|
||
send_update(
|
||
&session,
|
||
&info.session_id,
|
||
make_rename_update(inst_id, &new_name),
|
||
);
|
||
state.current_name = new_name;
|
||
}
|
||
2 => {
|
||
send_update(
|
||
&session,
|
||
&info.session_id,
|
||
make_class_update(inst_id, new_class),
|
||
);
|
||
state.current_class = new_class.to_string();
|
||
}
|
||
_ => {
|
||
send_update(
|
||
&session,
|
||
&info.session_id,
|
||
make_combined_update(
|
||
inst_id,
|
||
Some(&new_name),
|
||
Some(new_class),
|
||
Some(&new_source),
|
||
),
|
||
);
|
||
state.current_name = new_name;
|
||
state.current_class = new_class.to_string();
|
||
state.current_source = new_source;
|
||
}
|
||
}
|
||
}
|
||
|
||
wait_for_settle();
|
||
let expected = src.join(state.expected_file());
|
||
assert!(
|
||
expected.is_file(),
|
||
"Expected file {} to exist after {} combined fuzz ops. State: name={}, class={}",
|
||
expected.display(),
|
||
iterations,
|
||
state.current_name,
|
||
state.current_class,
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 45: Fuzz multi-instance
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn fuzz_multi_instance_15_iterations() {
|
||
run_serve_test("syncback_stress", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let original_names = ["Alpha", "Bravo", "Charlie", "Delta", "Echo"];
|
||
|
||
for seed in 1u64..=15 {
|
||
let mut rng = XorShift64::new(seed);
|
||
|
||
// Pick 1-5 instances to update
|
||
let count = rng.range(1, 6) as usize;
|
||
let (session_id, _rs_id, instances) = get_stress_instances(&session);
|
||
|
||
let updates: Vec<InstanceUpdate> = instances
|
||
.iter()
|
||
.take(count)
|
||
.enumerate()
|
||
.map(|(i, (id, _name))| {
|
||
make_source_update(*id, &format!("-- fuzz mi s{} i{}", seed, i))
|
||
})
|
||
.collect();
|
||
|
||
let write_request = WriteRequest {
|
||
session_id,
|
||
removed: vec![],
|
||
added: HashMap::new(),
|
||
updated: updates,
|
||
};
|
||
session.post_api_write(&write_request).unwrap();
|
||
thread::sleep(Duration::from_millis(100));
|
||
}
|
||
|
||
wait_for_settle();
|
||
|
||
// All 5 files should still exist (we only did Source updates)
|
||
for name in &original_names {
|
||
assert_file_exists(
|
||
&src.join(format!("{}.luau", name)),
|
||
&format!("{}.luau after 100 fuzz iterations", name),
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test 46: Fuzz directory format operations
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn fuzz_directory_format_operations_15_iterations() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let class_options = ["ModuleScript", "Script", "LocalScript"];
|
||
let mut current_name = "DirModuleWithChildren".to_string();
|
||
|
||
for seed in 1u64..=15 {
|
||
let mut rng = XorShift64::new(seed);
|
||
let is_rename = rng.next().is_multiple_of(2);
|
||
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let found = rs_read
|
||
.instances
|
||
.iter()
|
||
.find(|(_, inst)| inst.name == current_name);
|
||
let inst_id = match found {
|
||
Some((&id, _)) => id,
|
||
None => {
|
||
thread::sleep(Duration::from_millis(500));
|
||
continue;
|
||
}
|
||
};
|
||
|
||
if is_rename {
|
||
let new_name = format!("DirFz{}", seed);
|
||
send_update_fast(
|
||
&session,
|
||
&info.session_id,
|
||
make_rename_update(inst_id, &new_name),
|
||
);
|
||
current_name = new_name;
|
||
} else {
|
||
let new_class = *rng.choose(&class_options);
|
||
send_update_fast(
|
||
&session,
|
||
&info.session_id,
|
||
make_class_update(inst_id, new_class),
|
||
);
|
||
}
|
||
thread::sleep(Duration::from_millis(200));
|
||
}
|
||
|
||
wait_for_settle();
|
||
|
||
// Directory should exist with children intact
|
||
let final_dir = src.join(¤t_name);
|
||
assert!(
|
||
final_dir.is_dir(),
|
||
"Final directory {} should exist",
|
||
final_dir.display()
|
||
);
|
||
assert_file_exists(
|
||
&final_dir.join("ChildA.luau"),
|
||
"ChildA.luau after 100 iterations",
|
||
);
|
||
assert_file_exists(
|
||
&final_dir.join("ChildB.luau"),
|
||
"ChildB.luau after 100 iterations",
|
||
);
|
||
});
|
||
}
|
||
|
||
// ===========================================================================
|
||
// PART 3: File Watcher Stress Tests (Tests 47-66)
|
||
// ===========================================================================
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests 47-48: Rapid source edits on disk
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn watcher_rapid_source_edits_on_disk_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let file_path = session.path().join("src").join("existing.luau");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
for i in 1..=10 {
|
||
fs::write(&file_path, format!("-- disk v{}\nreturn {{}}", i)).unwrap();
|
||
thread::sleep(Duration::from_millis(30));
|
||
}
|
||
|
||
poll_tree_source(&session, rs_id, "existing", "-- disk v10", 3000);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn watcher_burst_writes_100x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let file_path = session.path().join("src").join("existing.luau");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
for i in 0..100 {
|
||
fs::write(&file_path, format!("-- burst {}\nreturn {{}}", i)).unwrap();
|
||
}
|
||
fs::write(&file_path, "-- burst final\nreturn {}").unwrap();
|
||
|
||
poll_tree_source(&session, rs_id, "existing", "-- burst final", 3000);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests 49-50: Filesystem rename
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
#[cfg_attr(target_os = "macos", ignore)]
|
||
fn watcher_filesystem_rename_chain_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let original_content = fs::read_to_string(src.join("existing.luau")).unwrap();
|
||
|
||
let mut current_file = src.join("existing.luau");
|
||
for i in 1..=10 {
|
||
let new_file = src.join(format!("R{}.luau", i));
|
||
fs::rename(¤t_file, &new_file).unwrap();
|
||
current_file = new_file;
|
||
thread::sleep(Duration::from_millis(STRESS_OP_DELAY_MS));
|
||
}
|
||
|
||
poll_tree_has_instance(&session, rs_id, "R10", STRESS_POLL_TIMEOUT_MS);
|
||
|
||
let content = fs::read_to_string(¤t_file).unwrap();
|
||
assert_eq!(
|
||
original_content, content,
|
||
"Content preserved through renames"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn watcher_rename_with_content_change() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
// Atomic overwrite: write temp file, rename over existing
|
||
let temp = src.join("existing.luau.tmp");
|
||
fs::write(&temp, "-- atomic overwrite content\nreturn {}").unwrap();
|
||
fs::rename(&temp, src.join("existing.luau")).unwrap();
|
||
|
||
poll_tree_source(
|
||
&session,
|
||
rs_id,
|
||
"existing",
|
||
"-- atomic overwrite content",
|
||
3000,
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests 51-53: Delete + recreate via filesystem
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn watcher_delete_recreate_immediate() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let file_path = session.path().join("src").join("existing.luau");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
fs::remove_file(&file_path).unwrap();
|
||
fs::write(&file_path, "-- recreated immediately\nreturn {}").unwrap();
|
||
|
||
poll_tree_source(
|
||
&session,
|
||
rs_id,
|
||
"existing",
|
||
"-- recreated immediately",
|
||
3000,
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn watcher_delete_recreate_cycle_5x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let file_path = session.path().join("src").join("existing.luau");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
for cycle in 1..=5 {
|
||
fs::remove_file(&file_path).unwrap();
|
||
thread::sleep(Duration::from_millis(50));
|
||
let content = format!("-- cycle {} content\nreturn {{}}", cycle);
|
||
fs::write(&file_path, &content).unwrap();
|
||
|
||
poll_tree_source(
|
||
&session,
|
||
rs_id,
|
||
"existing",
|
||
&format!("-- cycle {} content", cycle),
|
||
5000,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn watcher_delete_recreate_different_content() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let file_path = session.path().join("src").join("existing.luau");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
fs::remove_file(&file_path).unwrap();
|
||
// Wait for removal to propagate
|
||
thread::sleep(Duration::from_millis(300));
|
||
// Recreate with completely different content
|
||
fs::write(
|
||
&file_path,
|
||
"-- completely different\nlocal x = 42\nreturn x",
|
||
)
|
||
.unwrap();
|
||
|
||
poll_tree_source(&session, rs_id, "existing", "-- completely different", 5000);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests 54-57: Init file shenanigans
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn watcher_edit_init_file() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let init_path = src.join("DirModuleWithChildren").join("init.luau");
|
||
|
||
fs::write(&init_path, "-- edited init content\nreturn {}").unwrap();
|
||
|
||
poll_tree_source(
|
||
&session,
|
||
rs_id,
|
||
"DirModuleWithChildren",
|
||
"-- edited init content",
|
||
3000,
|
||
);
|
||
|
||
// Children should still exist
|
||
let dir_id = poll_tree_has_instance(&session, rs_id, "DirModuleWithChildren", 1000);
|
||
let child_read = session.get_api_read(dir_id).unwrap();
|
||
assert!(
|
||
child_read.instances.values().any(|i| i.name == "ChildA"),
|
||
"ChildA should still exist after init edit"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn watcher_init_type_cycling_10x() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let dir = src.join("DirModuleWithChildren");
|
||
|
||
// Cycle: init.luau -> init.server.luau -> init.local.luau -> init.luau -> ...
|
||
let transitions = [
|
||
("init.luau", "init.server.luau", "Script"),
|
||
("init.server.luau", "init.local.luau", "LocalScript"),
|
||
("init.local.luau", "init.luau", "ModuleScript"),
|
||
("init.luau", "init.server.luau", "Script"),
|
||
("init.server.luau", "init.local.luau", "LocalScript"),
|
||
("init.local.luau", "init.luau", "ModuleScript"),
|
||
("init.luau", "init.server.luau", "Script"),
|
||
("init.server.luau", "init.local.luau", "LocalScript"),
|
||
("init.local.luau", "init.luau", "ModuleScript"),
|
||
("init.luau", "init.server.luau", "Script"),
|
||
];
|
||
|
||
for (from, to, expected_class) in &transitions {
|
||
fs::rename(dir.join(from), dir.join(to)).unwrap();
|
||
poll_tree_class(
|
||
&session,
|
||
rs_id,
|
||
"DirModuleWithChildren",
|
||
expected_class,
|
||
3000,
|
||
);
|
||
}
|
||
|
||
// Children should survive all transitions
|
||
let dir_id = poll_tree_has_instance(&session, rs_id, "DirModuleWithChildren", 1000);
|
||
let child_read = session.get_api_read(dir_id).unwrap();
|
||
assert!(
|
||
child_read.instances.values().any(|i| i.name == "ChildA"),
|
||
"ChildA should survive init type cycling"
|
||
);
|
||
assert!(
|
||
child_read.instances.values().any(|i| i.name == "ChildB"),
|
||
"ChildB should survive init type cycling"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn watcher_delete_init_file() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let init_path = src.join("DirModuleWithChildren").join("init.luau");
|
||
|
||
fs::remove_file(&init_path).unwrap();
|
||
|
||
// Without init file, directory becomes a Folder
|
||
poll_tree_class(&session, rs_id, "DirModuleWithChildren", "Folder", 3000);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn watcher_replace_init_file_type() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let dir = src.join("DirModuleWithChildren");
|
||
let old_init = dir.join("init.luau");
|
||
let new_init = dir.join("init.server.luau");
|
||
let content = fs::read_to_string(&old_init).unwrap();
|
||
|
||
fs::remove_file(&old_init).unwrap();
|
||
fs::write(&new_init, &content).unwrap();
|
||
|
||
poll_tree_class(&session, rs_id, "DirModuleWithChildren", "Script", 3000);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests 58-60: Singular <-> directory format conversion
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn watcher_standalone_to_directory_conversion() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let standalone = src.join("StandaloneModule.luau");
|
||
let original = fs::read_to_string(&standalone).unwrap();
|
||
|
||
// Convert: remove file, create directory with init + child
|
||
fs::remove_file(&standalone).unwrap();
|
||
let dir = src.join("StandaloneModule");
|
||
fs::create_dir(&dir).unwrap();
|
||
fs::write(dir.join("init.luau"), &original).unwrap();
|
||
fs::write(dir.join("ChildNew.luau"), "-- new child\nreturn {}").unwrap();
|
||
|
||
poll_tree_has_child(&session, rs_id, "StandaloneModule", "ChildNew", 5000);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn watcher_directory_to_standalone_conversion() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let dir = src.join("DirModuleWithChildren");
|
||
let init_content = fs::read_to_string(dir.join("init.luau")).unwrap();
|
||
|
||
// Convert: remove directory, create standalone file
|
||
fs::remove_dir_all(&dir).unwrap();
|
||
fs::write(src.join("DirModuleWithChildren.luau"), &init_content).unwrap();
|
||
|
||
// Wait and verify no children remain
|
||
thread::sleep(Duration::from_millis(500));
|
||
let id = poll_tree_has_instance(&session, rs_id, "DirModuleWithChildren", 5000);
|
||
// Read children
|
||
let child_read = session.get_api_read(id).unwrap();
|
||
let child_count = child_read
|
||
.instances
|
||
.values()
|
||
.filter(|i| i.name != "DirModuleWithChildren")
|
||
.count();
|
||
assert!(
|
||
child_count == 0,
|
||
"Standalone should have no children, found {}",
|
||
child_count
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn watcher_format_flip_flop_5x() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let standalone_path = src.join("StandaloneModule.luau");
|
||
let dir_path = src.join("StandaloneModule");
|
||
|
||
for cycle in 1..=5 {
|
||
// Standalone -> Directory
|
||
if standalone_path.exists() {
|
||
fs::remove_file(&standalone_path).unwrap();
|
||
}
|
||
if !dir_path.exists() {
|
||
fs::create_dir(&dir_path).unwrap();
|
||
}
|
||
fs::write(
|
||
dir_path.join("init.luau"),
|
||
format!("-- dir cycle {}\nreturn {{}}", cycle),
|
||
)
|
||
.unwrap();
|
||
fs::write(
|
||
dir_path.join("Child.luau"),
|
||
format!("-- child cycle {}", cycle),
|
||
)
|
||
.unwrap();
|
||
|
||
poll_tree_has_child(&session, rs_id, "StandaloneModule", "Child", 5000);
|
||
|
||
// Directory -> Standalone
|
||
fs::remove_dir_all(&dir_path).unwrap();
|
||
fs::write(
|
||
&standalone_path,
|
||
format!("-- standalone cycle {}\nreturn {{}}", cycle),
|
||
)
|
||
.unwrap();
|
||
|
||
poll_tree_source(
|
||
&session,
|
||
rs_id,
|
||
"StandaloneModule",
|
||
&format!("-- standalone cycle {}", cycle),
|
||
5000,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests 61-62: Editor save patterns
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn watcher_atomic_save_pattern() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let file = src.join("existing.luau");
|
||
let temp = src.join("existing.luau.tmp");
|
||
|
||
for i in 1..=5 {
|
||
let content = format!("-- atomic save v{}\nreturn {{}}", i);
|
||
fs::write(&temp, &content).unwrap();
|
||
fs::rename(&temp, &file).unwrap();
|
||
|
||
poll_tree_source(
|
||
&session,
|
||
rs_id,
|
||
"existing",
|
||
&format!("-- atomic save v{}", i),
|
||
3000,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn watcher_backup_rename_write_pattern() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let file = src.join("existing.luau");
|
||
let backup = src.join("existing.luau.bak");
|
||
|
||
// Backup-rename-write: rename existing to .bak, write new content
|
||
fs::rename(&file, &backup).unwrap();
|
||
fs::write(&file, "-- backup-rename-write content\nreturn {}").unwrap();
|
||
|
||
poll_tree_source(
|
||
&session,
|
||
rs_id,
|
||
"existing",
|
||
"-- backup-rename-write content",
|
||
3000,
|
||
);
|
||
|
||
// Clean up backup
|
||
if backup.exists() {
|
||
fs::remove_file(&backup).unwrap();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests 63-64: Parent directory operations
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn watcher_parent_directory_rename() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let old_dir = src.join("DirModuleWithChildren");
|
||
let new_dir = src.join("RenamedDir");
|
||
|
||
fs::rename(&old_dir, &new_dir).unwrap();
|
||
|
||
poll_tree_has_instance(&session, rs_id, "RenamedDir", 5000);
|
||
poll_tree_no_instance(&session, rs_id, "DirModuleWithChildren", 3000);
|
||
|
||
// Children should be intact
|
||
let dir_id = poll_tree_has_instance(&session, rs_id, "RenamedDir", 1000);
|
||
let child_read = session.get_api_read(dir_id).unwrap();
|
||
assert!(
|
||
child_read.instances.values().any(|i| i.name == "ChildA"),
|
||
"ChildA should be in renamed directory"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn watcher_parent_directory_delete_all() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
fs::remove_dir_all(src.join("DirModuleWithChildren")).unwrap();
|
||
|
||
poll_tree_no_instance(&session, rs_id, "DirModuleWithChildren", 5000);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests 65-66: Concurrent filesystem + API
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn watcher_filesystem_and_api_concurrent() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
// Filesystem edit on StandaloneModule
|
||
let standalone_file = src.join("StandaloneModule.luau");
|
||
fs::write(&standalone_file, "-- fs edited module\nreturn {}").unwrap();
|
||
|
||
// API edit on StandaloneScript
|
||
let (session_id, script_id) = get_format_transitions_instance(&session, "StandaloneScript");
|
||
send_update_no_wait(
|
||
&session,
|
||
&session_id,
|
||
make_source_update(script_id, "-- api edited script"),
|
||
);
|
||
|
||
// Both should propagate
|
||
poll_tree_source(
|
||
&session,
|
||
rs_id,
|
||
"StandaloneModule",
|
||
"-- fs edited module",
|
||
3000,
|
||
);
|
||
let script_file = src.join("StandaloneScript.server.luau");
|
||
// Wait for API write to complete
|
||
thread::sleep(Duration::from_millis(500));
|
||
let script_content = fs::read_to_string(&script_file).unwrap();
|
||
assert!(
|
||
script_content.contains("-- api edited script"),
|
||
"Script should be updated via API, got: {}",
|
||
script_content
|
||
);
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn watcher_multi_file_simultaneous_edits() {
|
||
run_serve_test("syncback_stress", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let names = ["Alpha", "Bravo", "Charlie", "Delta", "Echo"];
|
||
|
||
// Write all 5 files as fast as possible
|
||
for (i, name) in names.iter().enumerate() {
|
||
fs::write(
|
||
src.join(format!("{}.luau", name)),
|
||
format!("-- simultaneous edit {}\nreturn {{}}", i),
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
// Poll for all 5 to update
|
||
for (i, name) in names.iter().enumerate() {
|
||
poll_tree_source(
|
||
&session,
|
||
rs_id,
|
||
name,
|
||
&format!("-- simultaneous edit {}", i),
|
||
5000,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ===========================================================================
|
||
// PART 3: VFS & Change Processor Stress Tests
|
||
//
|
||
// These tests exercise every combination of rename, ClassName change, Source
|
||
// write, and format transition (standalone ↔ directory) under maximum
|
||
// pressure — zero-wait fire-and-forget, rapid interleaving, concurrent API +
|
||
// filesystem ops, encoded names through all paths, and multi-instance
|
||
// batches. The goal is to find every race condition in event suppression,
|
||
// metadata updates, and the snapshot pipeline.
|
||
// ===========================================================================
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Section A: No-Wait Rapid Chains (maximum VFS pressure)
|
||
//
|
||
// Every request is sent with ZERO sleep between them. The server and change
|
||
// processor must handle a firehose of overlapping PatchSets and VFS events.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// No-wait rename chain: 10 renames fired as fast as the HTTP stack allows.
|
||
#[test]
|
||
fn no_wait_rename_chain_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
|
||
// Get instance ID once before the loop — the Ref is stable across
|
||
// renames, and reading the tree mid-chain is racy because the
|
||
// change_processor may not have applied the previous rename yet.
|
||
let (session_id, _rs_id, id) = get_rs_and_existing(&session);
|
||
|
||
for i in 1..=10 {
|
||
let new_name = format!("NWR{}", i);
|
||
send_update_no_wait(&session, &session_id, make_rename_update(id, &new_name));
|
||
}
|
||
|
||
wait_for_settle();
|
||
assert_file_exists(&src.join("NWR10.luau"), "NWR10.luau after no-wait chain");
|
||
for i in 1..10 {
|
||
assert_not_exists(
|
||
&src.join(format!("NWR{}.luau", i)),
|
||
&format!("Intermediate NWR{}.luau", i),
|
||
);
|
||
}
|
||
assert_not_exists(&src.join("existing.luau"), "Original file");
|
||
});
|
||
}
|
||
|
||
/// No-wait ClassName cycling: 10 class changes fired instantly.
|
||
#[test]
|
||
fn no_wait_classchange_chain_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let classes = [
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"LocalScript",
|
||
];
|
||
|
||
for class in &classes {
|
||
let (session_id, _rs_id, id) = get_rs_and_existing(&session);
|
||
send_update_no_wait(&session, &session_id, make_class_update(id, class));
|
||
}
|
||
|
||
wait_for_settle();
|
||
// Final: LocalScript -> existing.local.luau
|
||
verify_instance_file(&src, "existing", "LocalScript", None);
|
||
});
|
||
}
|
||
|
||
/// No-wait combined rename + source: 10 fire-and-forget.
|
||
#[test]
|
||
fn no_wait_combined_rename_source_chain_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
|
||
let (session_id, _rs_id, id) = get_rs_and_existing(&session);
|
||
|
||
for i in 1..=10 {
|
||
let new_name = format!("NWRS{}", i);
|
||
send_update_no_wait(
|
||
&session,
|
||
&session_id,
|
||
make_combined_update(id, Some(&new_name), None, Some(&format!("-- nwrs {}", i))),
|
||
);
|
||
}
|
||
|
||
wait_for_settle();
|
||
verify_instance_file(&src, "NWRS10", "ModuleScript", Some("-- nwrs 10"));
|
||
});
|
||
}
|
||
|
||
/// No-wait combined rename + ClassName: 10 fire-and-forget.
|
||
#[test]
|
||
fn no_wait_combined_rename_class_chain_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let classes = [
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
];
|
||
|
||
let (session_id, _rs_id, id) = get_rs_and_existing(&session);
|
||
|
||
for (i, class) in classes.iter().enumerate() {
|
||
let new_name = format!("NWRC{}", i + 1);
|
||
send_update_no_wait(
|
||
&session,
|
||
&session_id,
|
||
make_combined_update(id, Some(&new_name), Some(class), None),
|
||
);
|
||
}
|
||
|
||
wait_for_settle();
|
||
// Final: Script -> NWRC10.server.luau
|
||
verify_instance_file(&src, "NWRC10", "Script", None);
|
||
});
|
||
}
|
||
|
||
/// No-wait combined rename + ClassName + source: the ultimate firehose.
|
||
#[test]
|
||
fn no_wait_combined_all_three_chain_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let classes = [
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"LocalScript",
|
||
];
|
||
|
||
let (session_id, _rs_id, id) = get_rs_and_existing(&session);
|
||
|
||
for (i, class) in classes.iter().enumerate() {
|
||
let new_name = format!("NWA{}", i + 1);
|
||
send_update_no_wait(
|
||
&session,
|
||
&session_id,
|
||
make_combined_update(
|
||
id,
|
||
Some(&new_name),
|
||
Some(class),
|
||
Some(&format!("-- nwa {}", i + 1)),
|
||
),
|
||
);
|
||
}
|
||
|
||
wait_for_settle();
|
||
// Final: LocalScript -> NWA10.local.luau with "-- nwa 10"
|
||
verify_instance_file(&src, "NWA10", "LocalScript", Some("-- nwa 10"));
|
||
});
|
||
}
|
||
|
||
/// No-wait directory rename chain: 10 directory renames fired instantly.
|
||
#[test]
|
||
fn no_wait_directory_rename_chain_10x() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
|
||
let (session_id, id) = get_format_transitions_instance(&session, "DirModuleWithChildren");
|
||
|
||
for i in 1..=10 {
|
||
let new_name = format!("NWD{}", i);
|
||
send_update_no_wait(&session, &session_id, make_rename_update(id, &new_name));
|
||
}
|
||
|
||
wait_for_settle();
|
||
let final_dir = src.join("NWD10");
|
||
assert!(final_dir.is_dir(), "NWD10 should exist as directory");
|
||
assert_file_exists(&final_dir.join("init.luau"), "init.luau in NWD10");
|
||
assert_file_exists(&final_dir.join("ChildA.luau"), "ChildA in NWD10");
|
||
assert_file_exists(&final_dir.join("ChildB.luau"), "ChildB in NWD10");
|
||
});
|
||
}
|
||
|
||
/// No-wait directory ClassName cycling: 10 init file type changes instantly.
|
||
#[test]
|
||
fn no_wait_directory_class_chain_10x() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let dir = src.join("DirModuleWithChildren");
|
||
let classes = [
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"LocalScript",
|
||
];
|
||
|
||
for class in &classes {
|
||
let (session_id, id) =
|
||
get_format_transitions_instance(&session, "DirModuleWithChildren");
|
||
send_update_no_wait(&session, &session_id, make_class_update(id, class));
|
||
}
|
||
|
||
wait_for_settle();
|
||
// Final: LocalScript -> init.local.luau
|
||
assert_file_exists(
|
||
&dir.join("init.local.luau"),
|
||
"init.local.luau after no-wait class chain",
|
||
);
|
||
assert_file_exists(&dir.join("ChildA.luau"), "ChildA survived class chain");
|
||
assert_file_exists(&dir.join("ChildB.luau"), "ChildB survived class chain");
|
||
});
|
||
}
|
||
|
||
/// No-wait directory all-three: rename + class + source on init, 10x instant.
|
||
#[test]
|
||
fn no_wait_directory_all_three_chain_10x() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let classes = [
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
];
|
||
|
||
let (session_id, id) = get_format_transitions_instance(&session, "DirModuleWithChildren");
|
||
|
||
for (i, class) in classes.iter().enumerate() {
|
||
let new_name = format!("NWDA{}", i + 1);
|
||
send_update_no_wait(
|
||
&session,
|
||
&session_id,
|
||
make_combined_update(
|
||
id,
|
||
Some(&new_name),
|
||
Some(class),
|
||
Some(&format!("-- nwda {}", i + 1)),
|
||
),
|
||
);
|
||
}
|
||
|
||
wait_for_settle();
|
||
let final_dir = src.join("NWDA10");
|
||
assert!(final_dir.is_dir(), "NWDA10 should be a directory");
|
||
// Final class: Script -> init.server.luau
|
||
assert_file_exists(
|
||
&final_dir.join("init.server.luau"),
|
||
"init.server.luau in NWDA10",
|
||
);
|
||
let content = fs::read_to_string(final_dir.join("init.server.luau")).unwrap();
|
||
assert!(
|
||
content.contains("-- nwda 10"),
|
||
"Final source should contain '-- nwda 10', got: {}",
|
||
content
|
||
);
|
||
assert_file_exists(&final_dir.join("ChildA.luau"), "ChildA in NWDA10");
|
||
assert_file_exists(&final_dir.join("ChildB.luau"), "ChildB in NWDA10");
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Section B: Interleaved Rename + ClassName Combinations
|
||
//
|
||
// Tests that alternate between different operations on each step to maximise
|
||
// the chance of stale metadata / wrong instigating_source.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Alternate: even steps rename, odd steps change class.
|
||
#[test]
|
||
fn alternating_rename_classchange_10x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let class_seq = [
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
];
|
||
let mut current_name = "existing".to_string();
|
||
let mut current_class = "ModuleScript";
|
||
|
||
for i in 1..=10 {
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let (id, _) = find_by_name(&rs_read.instances, ¤t_name);
|
||
|
||
if i % 2 == 0 {
|
||
// Rename on even steps
|
||
let new_name = format!("Alt{}", i);
|
||
send_update_fast(
|
||
&session,
|
||
&info.session_id,
|
||
make_rename_update(id, &new_name),
|
||
);
|
||
current_name = new_name;
|
||
} else {
|
||
// Class change on odd steps
|
||
let new_class = class_seq[(i / 2) % class_seq.len()];
|
||
send_update_fast(&session, &info.session_id, make_class_update(id, new_class));
|
||
current_class = new_class;
|
||
}
|
||
}
|
||
|
||
wait_for_settle();
|
||
let suffix = class_suffix(current_class);
|
||
let expected = src.join(format!("{}{}.luau", current_name, suffix));
|
||
assert!(
|
||
expected.is_file(),
|
||
"Expected {}, class={}",
|
||
expected.display(),
|
||
current_class
|
||
);
|
||
});
|
||
}
|
||
|
||
/// 5 renames, then 5 class changes, rapid fire.
|
||
#[test]
|
||
fn rename_burst_then_class_burst() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let mut current_name = "existing".to_string();
|
||
|
||
// 5 rapid renames
|
||
for i in 1..=5 {
|
||
let new_name = format!("RB{}", i);
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let (id, _) = find_by_name(&rs_read.instances, ¤t_name);
|
||
send_update_fast(
|
||
&session,
|
||
&info.session_id,
|
||
make_rename_update(id, &new_name),
|
||
);
|
||
current_name = new_name;
|
||
}
|
||
|
||
// 5 rapid class changes
|
||
let classes = [
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
];
|
||
for class in &classes {
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let (id, _) = find_by_name(&rs_read.instances, ¤t_name);
|
||
send_update_fast(&session, &info.session_id, make_class_update(id, class));
|
||
}
|
||
|
||
wait_for_settle();
|
||
// Final: RB5 as LocalScript -> RB5.local.luau
|
||
verify_instance_file(&src, "RB5", "LocalScript", None);
|
||
});
|
||
}
|
||
|
||
/// Directory: alternate rename and class change on each step.
|
||
#[test]
|
||
fn directory_alternating_rename_classchange_10x() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let class_seq = [
|
||
"Script",
|
||
"LocalScript",
|
||
"ModuleScript",
|
||
"Script",
|
||
"LocalScript",
|
||
];
|
||
let mut current_name = "DirModuleWithChildren".to_string();
|
||
let mut current_class = "ModuleScript";
|
||
|
||
for i in 1..=10 {
|
||
let (session_id, id) = get_format_transitions_instance(&session, ¤t_name);
|
||
if i % 2 == 0 {
|
||
let new_name = format!("DAlt{}", i);
|
||
send_update_fast(&session, &session_id, make_rename_update(id, &new_name));
|
||
current_name = new_name;
|
||
} else {
|
||
let new_class = class_seq[(i / 2) % class_seq.len()];
|
||
send_update_fast(&session, &session_id, make_class_update(id, new_class));
|
||
current_class = new_class;
|
||
}
|
||
}
|
||
|
||
wait_for_settle();
|
||
let final_dir = src.join(¤t_name);
|
||
assert!(final_dir.is_dir(), "{} should be a directory", current_name);
|
||
let init_suffix = class_suffix(current_class);
|
||
let init_name = if init_suffix.is_empty() {
|
||
"init.luau".to_string()
|
||
} else {
|
||
format!("init{}.luau", init_suffix)
|
||
};
|
||
assert_file_exists(
|
||
&final_dir.join(&init_name),
|
||
&format!("{} in {}", init_name, current_name),
|
||
);
|
||
assert_file_exists(&final_dir.join("ChildA.luau"), "ChildA survived");
|
||
assert_file_exists(&final_dir.join("ChildB.luau"), "ChildB survived");
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Section C: Format Transition Stress (standalone ↔ directory via watcher)
|
||
//
|
||
// Tests that convert files between standalone and directory format via direct
|
||
// filesystem manipulation, then layer renames and class changes on top.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Convert standalone to directory on disk, then immediately rename the dir.
|
||
#[test]
|
||
fn watcher_standalone_to_dir_then_rename() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let standalone = src.join("StandaloneModule.luau");
|
||
let original = fs::read_to_string(&standalone).unwrap();
|
||
|
||
// Step 1: Convert standalone -> directory
|
||
fs::remove_file(&standalone).unwrap();
|
||
let dir = src.join("StandaloneModule");
|
||
fs::create_dir(&dir).unwrap();
|
||
fs::write(dir.join("init.luau"), &original).unwrap();
|
||
fs::write(dir.join("NewChild.luau"), "-- new child\nreturn {}").unwrap();
|
||
|
||
// Wait for conversion to register
|
||
poll_tree_has_child(&session, rs_id, "StandaloneModule", "NewChild", 5000);
|
||
|
||
// Step 2: Immediately rename the directory
|
||
let new_dir = src.join("RenamedAfterConversion");
|
||
fs::rename(&dir, &new_dir).unwrap();
|
||
|
||
poll_tree_has_instance(&session, rs_id, "RenamedAfterConversion", 5000);
|
||
poll_tree_no_instance(&session, rs_id, "StandaloneModule", 3000);
|
||
});
|
||
}
|
||
|
||
/// Convert directory to standalone on disk, then immediately rename the file.
|
||
#[test]
|
||
fn watcher_dir_to_standalone_then_rename() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let dir = src.join("DirModuleWithChildren");
|
||
let init_content = fs::read_to_string(dir.join("init.luau")).unwrap();
|
||
|
||
// Step 1: Convert directory -> standalone
|
||
fs::remove_dir_all(&dir).unwrap();
|
||
let standalone = src.join("DirModuleWithChildren.luau");
|
||
fs::write(&standalone, &init_content).unwrap();
|
||
|
||
// Wait for conversion
|
||
poll_tree_has_instance(&session, rs_id, "DirModuleWithChildren", 5000);
|
||
|
||
// Step 2: Rename the file
|
||
let renamed = src.join("CollapsedAndRenamed.luau");
|
||
fs::rename(&standalone, &renamed).unwrap();
|
||
|
||
poll_tree_has_instance(&session, rs_id, "CollapsedAndRenamed", 5000);
|
||
poll_tree_no_instance(&session, rs_id, "DirModuleWithChildren", 3000);
|
||
});
|
||
}
|
||
|
||
/// Convert to directory, then change the init file type.
|
||
#[test]
|
||
fn watcher_standalone_to_dir_then_change_init_type() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let standalone = src.join("StandaloneModule.luau");
|
||
let original = fs::read_to_string(&standalone).unwrap();
|
||
|
||
// Convert standalone -> directory with init.luau
|
||
fs::remove_file(&standalone).unwrap();
|
||
let dir = src.join("StandaloneModule");
|
||
fs::create_dir(&dir).unwrap();
|
||
fs::write(dir.join("init.luau"), &original).unwrap();
|
||
|
||
poll_tree_class(&session, rs_id, "StandaloneModule", "ModuleScript", 5000);
|
||
|
||
// Change init type: init.luau -> init.server.luau
|
||
fs::rename(dir.join("init.luau"), dir.join("init.server.luau")).unwrap();
|
||
|
||
poll_tree_class(&session, rs_id, "StandaloneModule", "Script", 5000);
|
||
});
|
||
}
|
||
|
||
/// Delete init.luau, recreate as init.server.luau (different type).
|
||
#[test]
|
||
fn watcher_init_delete_recreate_different_type() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let dir = src.join("DirModuleWithChildren");
|
||
|
||
// Delete init.luau
|
||
fs::remove_file(dir.join("init.luau")).unwrap();
|
||
|
||
// Becomes Folder first
|
||
poll_tree_class(&session, rs_id, "DirModuleWithChildren", "Folder", 3000);
|
||
|
||
// Recreate as init.server.luau
|
||
fs::write(
|
||
dir.join("init.server.luau"),
|
||
"-- now a Script\nprint('hello')",
|
||
)
|
||
.unwrap();
|
||
|
||
poll_tree_class(&session, rs_id, "DirModuleWithChildren", "Script", 5000);
|
||
|
||
// Children still intact
|
||
let dir_id = poll_tree_has_instance(&session, rs_id, "DirModuleWithChildren", 1000);
|
||
let child_read = session.get_api_read(dir_id).unwrap();
|
||
assert!(
|
||
child_read.instances.values().any(|i| i.name == "ChildA"),
|
||
"ChildA should survive init type transition"
|
||
);
|
||
});
|
||
}
|
||
|
||
/// Delete init file: children must survive as a Folder.
|
||
#[test]
|
||
fn watcher_init_delete_children_survive() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let dir = src.join("DirModuleWithChildren");
|
||
|
||
fs::remove_file(dir.join("init.luau")).unwrap();
|
||
|
||
// Should become Folder
|
||
poll_tree_class(&session, rs_id, "DirModuleWithChildren", "Folder", 3000);
|
||
|
||
// Children should still be there
|
||
let dir_id = poll_tree_has_instance(&session, rs_id, "DirModuleWithChildren", 1000);
|
||
let child_read = session.get_api_read(dir_id).unwrap();
|
||
assert!(
|
||
child_read.instances.values().any(|i| i.name == "ChildA"),
|
||
"ChildA should exist after init deletion"
|
||
);
|
||
assert!(
|
||
child_read.instances.values().any(|i| i.name == "ChildB"),
|
||
"ChildB should exist after init deletion"
|
||
);
|
||
});
|
||
}
|
||
|
||
/// Rapid format flip-flop with a rename mid-cycle.
|
||
#[test]
|
||
fn watcher_format_flip_flop_with_rename() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let standalone_path = src.join("StandaloneModule.luau");
|
||
let content = fs::read_to_string(&standalone_path).unwrap();
|
||
|
||
// Cycle 1: Standalone -> Directory
|
||
fs::remove_file(&standalone_path).unwrap();
|
||
let dir = src.join("StandaloneModule");
|
||
fs::create_dir(&dir).unwrap();
|
||
fs::write(dir.join("init.luau"), &content).unwrap();
|
||
fs::write(dir.join("C1.luau"), "-- c1").unwrap();
|
||
|
||
poll_tree_has_child(&session, rs_id, "StandaloneModule", "C1", 5000);
|
||
|
||
// Cycle 2: Rename directory while it's a directory
|
||
let renamed_dir = src.join("FlipRenamed");
|
||
fs::rename(&dir, &renamed_dir).unwrap();
|
||
|
||
poll_tree_has_instance(&session, rs_id, "FlipRenamed", 5000);
|
||
|
||
// Cycle 3: Collapse back to standalone
|
||
fs::remove_dir_all(&renamed_dir).unwrap();
|
||
fs::write(src.join("FlipRenamed.luau"), "-- collapsed back\nreturn {}").unwrap();
|
||
|
||
poll_tree_source(&session, rs_id, "FlipRenamed", "-- collapsed back", 5000);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Section D: Multi-Instance Concurrent VFS Stress
|
||
//
|
||
// Hammer multiple files simultaneously to generate a storm of VFS events.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Rename all 5 stress files on disk simultaneously.
|
||
#[test]
|
||
fn watcher_rename_5_files_simultaneously() {
|
||
run_serve_test("syncback_stress", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let names = ["Alpha", "Bravo", "Charlie", "Delta", "Echo"];
|
||
let new_names = ["A1", "B1", "C1", "D1", "E1"];
|
||
|
||
// Rename all 5 as fast as possible
|
||
for (old, new) in names.iter().zip(new_names.iter()) {
|
||
fs::rename(
|
||
src.join(format!("{}.luau", old)),
|
||
src.join(format!("{}.luau", new)),
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
// All new names should appear
|
||
for new in &new_names {
|
||
poll_tree_has_instance(&session, rs_id, new, 5000);
|
||
}
|
||
// All old names should be gone
|
||
for old in &names {
|
||
poll_tree_no_instance(&session, rs_id, old, 3000);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Delete + recreate all 5 stress files simultaneously.
|
||
#[test]
|
||
fn watcher_delete_recreate_5_files_simultaneously() {
|
||
run_serve_test("syncback_stress", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let names = ["Alpha", "Bravo", "Charlie", "Delta", "Echo"];
|
||
|
||
// Delete all
|
||
for name in &names {
|
||
fs::remove_file(src.join(format!("{}.luau", name))).unwrap();
|
||
}
|
||
// Recreate all immediately
|
||
for (i, name) in names.iter().enumerate() {
|
||
fs::write(
|
||
src.join(format!("{}.luau", name)),
|
||
format!("-- recreated {}\nreturn {{}}", i),
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
for (i, name) in names.iter().enumerate() {
|
||
poll_tree_source(&session, rs_id, name, &format!("-- recreated {}", i), 5000);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Mixed operations on 5 files: rename some, edit others, delete+recreate rest.
|
||
#[test]
|
||
fn watcher_mixed_rename_edit_delete_5_files() {
|
||
run_serve_test("syncback_stress", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
// Rename Alpha -> AlphaMoved
|
||
fs::rename(src.join("Alpha.luau"), src.join("AlphaMoved.luau")).unwrap();
|
||
// Edit Bravo
|
||
fs::write(src.join("Bravo.luau"), "-- bravo edited\nreturn {}").unwrap();
|
||
// Delete + recreate Charlie
|
||
fs::remove_file(src.join("Charlie.luau")).unwrap();
|
||
fs::write(src.join("Charlie.luau"), "-- charlie fresh\nreturn {}").unwrap();
|
||
// Rename Delta -> DeltaMoved
|
||
fs::rename(src.join("Delta.luau"), src.join("DeltaMoved.luau")).unwrap();
|
||
// Edit Echo
|
||
fs::write(src.join("Echo.luau"), "-- echo edited\nreturn {}").unwrap();
|
||
|
||
poll_tree_has_instance(&session, rs_id, "AlphaMoved", 5000);
|
||
poll_tree_source(&session, rs_id, "Bravo", "-- bravo edited", 5000);
|
||
poll_tree_source(&session, rs_id, "Charlie", "-- charlie fresh", 5000);
|
||
poll_tree_has_instance(&session, rs_id, "DeltaMoved", 5000);
|
||
poll_tree_source(&session, rs_id, "Echo", "-- echo edited", 5000);
|
||
});
|
||
}
|
||
|
||
/// API batch: rename + class + source on all 5 instances in one request.
|
||
#[test]
|
||
fn multi_instance_combined_rename_class_source_batch() {
|
||
run_serve_test("syncback_stress", |session, _redactions| {
|
||
let (session_id, _rs_id, instances) = get_stress_instances(&session);
|
||
let src = session.path().join("src");
|
||
|
||
let configs: Vec<(&str, &str, &str)> = vec![
|
||
("A_Script", "Script", "-- alpha as script"),
|
||
("B_Local", "LocalScript", "-- bravo as local"),
|
||
("C_Module", "ModuleScript", "-- charlie as module"),
|
||
("D_Script", "Script", "-- delta as script"),
|
||
("E_Local", "LocalScript", "-- echo as local"),
|
||
];
|
||
|
||
let updates: Vec<InstanceUpdate> = instances
|
||
.iter()
|
||
.zip(configs.iter())
|
||
.map(|((id, _), (name, class, source))| {
|
||
make_combined_update(*id, Some(name), Some(class), Some(source))
|
||
})
|
||
.collect();
|
||
|
||
let write_request = WriteRequest {
|
||
session_id,
|
||
removed: vec![],
|
||
added: HashMap::new(),
|
||
updated: updates,
|
||
};
|
||
session.post_api_write(&write_request).unwrap();
|
||
wait_for_settle();
|
||
|
||
for (name, class, source) in &configs {
|
||
verify_instance_file(&src, name, class, Some(source));
|
||
}
|
||
// All old files should be gone
|
||
for old in &["Alpha", "Bravo", "Charlie", "Delta", "Echo"] {
|
||
for sfx in &["", ".server", ".local"] {
|
||
assert_not_exists(
|
||
&src.join(format!("{}{}.luau", old, sfx)),
|
||
&format!("Old {}{}.luau", old, sfx),
|
||
);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Section E: Encoded Names Through All Operation Types
|
||
//
|
||
// Encoded path names (special chars like ? : ) through rename, class change,
|
||
// and source update paths to verify encoding is preserved throughout.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Encoded name: rapid rename + source.
|
||
#[test]
|
||
fn encoded_rename_and_source_rapid() {
|
||
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let mut current_name = "What?Module".to_string();
|
||
|
||
let names_and_sources = [
|
||
("Test?One", "-- test one"),
|
||
("Another:Name", "-- another name"),
|
||
("Back?Again", "-- back again"),
|
||
("Final:Encoded", "-- final encoded"),
|
||
];
|
||
|
||
for (new_name, new_source) in &names_and_sources {
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let (id, _) = find_by_name(&rs_read.instances, ¤t_name);
|
||
send_update_fast(
|
||
&session,
|
||
&info.session_id,
|
||
make_combined_update(id, Some(new_name), None, Some(new_source)),
|
||
);
|
||
current_name = new_name.to_string();
|
||
}
|
||
|
||
wait_for_settle();
|
||
// Final: Final:Encoded -> Final%COLON%Encoded.luau
|
||
let final_file = src.join("Final%COLON%Encoded.luau");
|
||
assert_file_exists(&final_file, "Final encoded file");
|
||
let content = fs::read_to_string(&final_file).unwrap();
|
||
assert!(
|
||
content.contains("-- final encoded"),
|
||
"Content should be final, got: {}",
|
||
content
|
||
);
|
||
});
|
||
}
|
||
|
||
/// Encoded name: rapid rename + ClassName.
|
||
#[test]
|
||
fn encoded_rename_and_classchange_rapid() {
|
||
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let (_session_id, _id) = get_encoded_names_instance(&session, "What?Module");
|
||
|
||
let ops: Vec<(&str, &str)> = vec![
|
||
("Enc?Script", "Script"),
|
||
("Enc:Local", "LocalScript"),
|
||
("Enc?Module", "ModuleScript"),
|
||
("Enc:Final", "Script"),
|
||
];
|
||
|
||
let mut current_name = "What?Module".to_string();
|
||
for (new_name, new_class) in &ops {
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let (id, _) = find_by_name(&rs_read.instances, ¤t_name);
|
||
send_update_fast(
|
||
&session,
|
||
&info.session_id,
|
||
make_combined_update(id, Some(new_name), Some(new_class), None),
|
||
);
|
||
current_name = new_name.to_string();
|
||
}
|
||
|
||
wait_for_settle();
|
||
// Final: Enc:Final as Script -> Enc%COLON%Final.server.luau
|
||
let final_file = src.join("Enc%COLON%Final.server.luau");
|
||
assert_file_exists(&final_file, "Final encoded+class file");
|
||
});
|
||
}
|
||
|
||
/// Encoded name: all three combined rapid.
|
||
#[test]
|
||
fn encoded_all_three_combined_rapid() {
|
||
run_serve_test("syncback_encoded_names", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
|
||
let ops: Vec<(&str, &str, &str)> = vec![
|
||
("A?B", "Script", "-- a?b script"),
|
||
("C:D", "LocalScript", "-- c:d local"),
|
||
("E?F", "ModuleScript", "-- e?f module"),
|
||
("G:H", "Script", "-- g:h script"),
|
||
("End?Now", "LocalScript", "-- end now"),
|
||
];
|
||
|
||
let mut current_name = "What?Module".to_string();
|
||
for (new_name, new_class, new_source) in &ops {
|
||
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 rs_read = session.get_api_read(rs_id).unwrap();
|
||
let (id, _) = find_by_name(&rs_read.instances, ¤t_name);
|
||
send_update_fast(
|
||
&session,
|
||
&info.session_id,
|
||
make_combined_update(id, Some(new_name), Some(new_class), Some(new_source)),
|
||
);
|
||
current_name = new_name.to_string();
|
||
}
|
||
|
||
wait_for_settle();
|
||
// Final: End?Now as LocalScript -> End%QUESTION%Now.local.luau
|
||
let final_file = src.join("End%QUESTION%Now.local.luau");
|
||
assert_file_exists(&final_file, "Final all-three encoded file");
|
||
let content = fs::read_to_string(&final_file).unwrap();
|
||
assert!(
|
||
content.contains("-- end now"),
|
||
"Content should be final, got: {}",
|
||
content
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Section F: Concurrent API + Filesystem Operations
|
||
//
|
||
// Hit the server from both directions at once: API writes (rename/class/
|
||
// source) on one instance while the filesystem changes another.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// API renames one instance while filesystem edits a different one.
|
||
#[test]
|
||
fn concurrent_api_rename_fs_edit_different_instances() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
// Filesystem: edit StandaloneModule
|
||
fs::write(
|
||
src.join("StandaloneModule.luau"),
|
||
"-- fs concurrent edit\nreturn {}",
|
||
)
|
||
.unwrap();
|
||
|
||
// API: rename StandaloneScript
|
||
let (session_id, script_id) = get_format_transitions_instance(&session, "StandaloneScript");
|
||
send_update_no_wait(
|
||
&session,
|
||
&session_id,
|
||
make_rename_update(script_id, "ScriptRenamed"),
|
||
);
|
||
|
||
// Both should propagate
|
||
poll_tree_source(
|
||
&session,
|
||
rs_id,
|
||
"StandaloneModule",
|
||
"-- fs concurrent edit",
|
||
5000,
|
||
);
|
||
wait_for_settle();
|
||
assert_file_exists(
|
||
&src.join("ScriptRenamed.server.luau"),
|
||
"ScriptRenamed.server.luau",
|
||
);
|
||
assert_not_exists(
|
||
&src.join("StandaloneScript.server.luau"),
|
||
"Old StandaloneScript.server.luau",
|
||
);
|
||
});
|
||
}
|
||
|
||
/// API class change + filesystem rename on different instances simultaneously.
|
||
#[test]
|
||
fn concurrent_api_classchange_fs_rename_different_instances() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
// Filesystem: rename StandaloneModule.luau -> MovedModule.luau
|
||
fs::rename(
|
||
src.join("StandaloneModule.luau"),
|
||
src.join("MovedModule.luau"),
|
||
)
|
||
.unwrap();
|
||
|
||
// API: change StandaloneScript's class to LocalScript
|
||
let (session_id, script_id) = get_format_transitions_instance(&session, "StandaloneScript");
|
||
send_update_no_wait(
|
||
&session,
|
||
&session_id,
|
||
make_class_update(script_id, "LocalScript"),
|
||
);
|
||
|
||
poll_tree_has_instance(&session, rs_id, "MovedModule", 5000);
|
||
wait_for_settle();
|
||
// StandaloneScript -> existing.local.luau (class changed to LocalScript)
|
||
assert_file_exists(
|
||
&src.join("StandaloneScript.local.luau"),
|
||
"StandaloneScript.local.luau after class change",
|
||
);
|
||
});
|
||
}
|
||
|
||
/// API rename + source on one instance, filesystem dir rename on another.
|
||
#[test]
|
||
fn concurrent_api_combined_fs_dir_rename() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
// Filesystem: rename DirModuleWithChildren -> MovedDir
|
||
fs::rename(src.join("DirModuleWithChildren"), src.join("MovedDir")).unwrap();
|
||
|
||
// API: rename + source on StandaloneModule
|
||
let (session_id, module_id) = get_format_transitions_instance(&session, "StandaloneModule");
|
||
send_update_no_wait(
|
||
&session,
|
||
&session_id,
|
||
make_combined_update(
|
||
module_id,
|
||
Some("ModuleRenamed"),
|
||
None,
|
||
Some("-- api renamed module"),
|
||
),
|
||
);
|
||
|
||
poll_tree_has_instance(&session, rs_id, "MovedDir", 5000);
|
||
wait_for_settle();
|
||
assert_file_exists(
|
||
&src.join("ModuleRenamed.luau"),
|
||
"ModuleRenamed.luau after API rename",
|
||
);
|
||
let content = fs::read_to_string(src.join("ModuleRenamed.luau")).unwrap();
|
||
assert!(
|
||
content.contains("-- api renamed module"),
|
||
"Content should reflect API update, got: {}",
|
||
content
|
||
);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Section G: Directory-Specific VFS Stress
|
||
//
|
||
// Hammer directory-format scripts with every combination of operations.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Directory: rename then immediately edit init source (API).
|
||
#[test]
|
||
fn directory_rename_then_source_update() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
|
||
// Rename
|
||
let (session_id, id) = get_format_transitions_instance(&session, "DirModuleWithChildren");
|
||
send_update_fast(&session, &session_id, make_rename_update(id, "DirRenamed"));
|
||
|
||
// Re-fetch after rename settles
|
||
let (session_id2, id2) = get_format_transitions_instance(&session, "DirRenamed");
|
||
send_update_fast(
|
||
&session,
|
||
&session_id2,
|
||
make_source_update(id2, "-- renamed then sourced"),
|
||
);
|
||
|
||
wait_for_settle();
|
||
let dir = src.join("DirRenamed");
|
||
assert!(dir.is_dir(), "DirRenamed should exist");
|
||
let content = fs::read_to_string(dir.join("init.luau")).unwrap();
|
||
assert!(
|
||
content.contains("-- renamed then sourced"),
|
||
"Init should have new source, got: {}",
|
||
content
|
||
);
|
||
assert_file_exists(&dir.join("ChildA.luau"), "ChildA in DirRenamed");
|
||
});
|
||
}
|
||
|
||
/// Directory: rename + class change combined in one update.
|
||
#[test]
|
||
fn directory_combined_rename_classchange() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
|
||
let (session_id, id) = get_format_transitions_instance(&session, "DirModuleWithChildren");
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
make_combined_update(id, Some("DirRC"), Some("Script"), None),
|
||
);
|
||
|
||
wait_for_settle();
|
||
let dir = src.join("DirRC");
|
||
assert!(dir.is_dir(), "DirRC should exist");
|
||
assert_file_exists(&dir.join("init.server.luau"), "init.server.luau in DirRC");
|
||
assert_file_exists(&dir.join("ChildA.luau"), "ChildA in DirRC");
|
||
assert_file_exists(&dir.join("ChildB.luau"), "ChildB in DirRC");
|
||
});
|
||
}
|
||
|
||
/// Directory: rename + class + source all in one update.
|
||
#[test]
|
||
fn directory_combined_all_three() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
|
||
let (session_id, id) = get_format_transitions_instance(&session, "DirModuleWithChildren");
|
||
send_update(
|
||
&session,
|
||
&session_id,
|
||
make_combined_update(
|
||
id,
|
||
Some("DirAll3"),
|
||
Some("LocalScript"),
|
||
Some("-- dir all three"),
|
||
),
|
||
);
|
||
|
||
wait_for_settle();
|
||
let dir = src.join("DirAll3");
|
||
assert!(dir.is_dir(), "DirAll3 should exist");
|
||
assert_file_exists(&dir.join("init.local.luau"), "init.local.luau in DirAll3");
|
||
let content = fs::read_to_string(dir.join("init.local.luau")).unwrap();
|
||
assert!(
|
||
content.contains("-- dir all three"),
|
||
"Init should have new source, got: {}",
|
||
content
|
||
);
|
||
assert_file_exists(&dir.join("ChildA.luau"), "ChildA in DirAll3");
|
||
assert_file_exists(&dir.join("ChildB.luau"), "ChildB in DirAll3");
|
||
});
|
||
}
|
||
|
||
/// Filesystem: rename dir, then delete init -> becomes Folder under new name.
|
||
#[test]
|
||
fn watcher_directory_rename_then_delete_init() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
// Rename directory
|
||
let old_dir = src.join("DirModuleWithChildren");
|
||
let new_dir = src.join("RenamedThenStripped");
|
||
fs::rename(&old_dir, &new_dir).unwrap();
|
||
|
||
poll_tree_has_instance(&session, rs_id, "RenamedThenStripped", 5000);
|
||
|
||
// Delete init file -> should become Folder
|
||
fs::remove_file(new_dir.join("init.luau")).unwrap();
|
||
|
||
poll_tree_class(&session, rs_id, "RenamedThenStripped", "Folder", 5000);
|
||
|
||
// Children should survive
|
||
let dir_id = poll_tree_has_instance(&session, rs_id, "RenamedThenStripped", 1000);
|
||
let child_read = session.get_api_read(dir_id).unwrap();
|
||
assert!(
|
||
child_read.instances.values().any(|i| i.name == "ChildA"),
|
||
"ChildA should survive rename + init deletion"
|
||
);
|
||
});
|
||
}
|
||
|
||
/// Filesystem: delete init, add different type, all while renaming dir.
|
||
#[test]
|
||
fn watcher_directory_init_swap_and_rename() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let dir = src.join("DirModuleWithChildren");
|
||
|
||
// Delete init.luau, rename dir, add init.server.luau
|
||
let content = fs::read_to_string(dir.join("init.luau")).unwrap();
|
||
fs::remove_file(dir.join("init.luau")).unwrap();
|
||
let new_dir = src.join("SwappedAndRenamed");
|
||
fs::rename(&dir, &new_dir).unwrap();
|
||
fs::write(new_dir.join("init.server.luau"), &content).unwrap();
|
||
|
||
poll_tree_has_instance(&session, rs_id, "SwappedAndRenamed", 5000);
|
||
poll_tree_class(&session, rs_id, "SwappedAndRenamed", "Script", 5000);
|
||
});
|
||
}
|
||
|
||
/// Rapid directory init type cycling + rename interleaved via filesystem.
|
||
#[test]
|
||
fn watcher_directory_rapid_init_cycling_and_rename() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let mut dir = src.join("DirModuleWithChildren");
|
||
|
||
// Step 1: init.luau -> init.server.luau
|
||
fs::rename(dir.join("init.luau"), dir.join("init.server.luau")).unwrap();
|
||
poll_tree_class(&session, rs_id, "DirModuleWithChildren", "Script", 3000);
|
||
|
||
// Step 2: rename dir
|
||
let new_dir = src.join("CycledDir");
|
||
fs::rename(&dir, &new_dir).unwrap();
|
||
dir = new_dir;
|
||
poll_tree_has_instance(&session, rs_id, "CycledDir", 5000);
|
||
|
||
// Step 3: init.server.luau -> init.local.luau
|
||
fs::rename(dir.join("init.server.luau"), dir.join("init.local.luau")).unwrap();
|
||
poll_tree_class(&session, rs_id, "CycledDir", "LocalScript", 5000);
|
||
|
||
// Step 4: rename dir again
|
||
let final_dir = src.join("FinalCycled");
|
||
fs::rename(&dir, &final_dir).unwrap();
|
||
poll_tree_has_instance(&session, rs_id, "FinalCycled", 5000);
|
||
|
||
// Children intact
|
||
assert_file_exists(&final_dir.join("ChildA.luau"), "ChildA in FinalCycled");
|
||
assert_file_exists(&final_dir.join("ChildB.luau"), "ChildB in FinalCycled");
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Section H: Extreme Back-to-Back Stress
|
||
//
|
||
// Tests designed to be the absolute worst case for the change processor:
|
||
// maximum overlap, maximum event storms, minimum settle time.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 20 no-wait renames: the absolute maximum rename pressure on a single file.
|
||
#[test]
|
||
fn extreme_no_wait_rename_chain_20x() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
|
||
let (session_id, _rs_id, id) = get_rs_and_existing(&session);
|
||
|
||
for i in 1..=20 {
|
||
let new_name = format!("X{}", i);
|
||
send_update_no_wait(&session, &session_id, make_rename_update(id, &new_name));
|
||
}
|
||
|
||
wait_for_settle();
|
||
assert_file_exists(&src.join("X20.luau"), "X20.luau after 20 no-wait renames");
|
||
assert_not_exists(&src.join("existing.luau"), "Original after 20 renames");
|
||
});
|
||
}
|
||
|
||
/// 15 no-wait all-three-combined on a directory: rename + class + source.
|
||
/// (Directory all-three is the most expensive per-operation: dir rename +
|
||
/// init rename + source write + many VFS events. 15 iterations keeps the
|
||
/// extreme pressure without timing out the HTTP stack, unlike standalone
|
||
/// renames where 20 is fine because each op is cheap.)
|
||
#[test]
|
||
fn extreme_no_wait_directory_all_three_15x() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let classes = ["Script", "LocalScript", "ModuleScript"];
|
||
|
||
let (session_id, id) = get_format_transitions_instance(&session, "DirModuleWithChildren");
|
||
|
||
for i in 1..=15 {
|
||
let new_name = format!("XD{}", i);
|
||
let new_class = classes[i % 3];
|
||
send_update_no_wait(
|
||
&session,
|
||
&session_id,
|
||
make_combined_update(
|
||
id,
|
||
Some(&new_name),
|
||
Some(new_class),
|
||
Some(&format!("-- xd {}", i)),
|
||
),
|
||
);
|
||
}
|
||
|
||
wait_for_settle();
|
||
let final_dir = src.join("XD15");
|
||
assert!(final_dir.is_dir(), "XD15 should be a directory");
|
||
// class at i=15: classes[15 % 3] = classes[0] = Script
|
||
assert_file_exists(
|
||
&final_dir.join("init.server.luau"),
|
||
"init.server.luau in XD15 (Script)",
|
||
);
|
||
let content = fs::read_to_string(final_dir.join("init.server.luau")).unwrap();
|
||
assert!(
|
||
content.contains("-- xd 15"),
|
||
"Final source in XD15, got: {}",
|
||
content
|
||
);
|
||
assert_file_exists(&final_dir.join("ChildA.luau"), "ChildA in XD15");
|
||
assert_file_exists(&final_dir.join("ChildB.luau"), "ChildB in XD15");
|
||
});
|
||
}
|
||
|
||
/// Multi-instance: rename all 5 stress files in rapid succession via API,
|
||
/// each with a different class and source — all no-wait.
|
||
#[test]
|
||
fn extreme_multi_instance_rename_class_source_no_wait() {
|
||
run_serve_test("syncback_stress", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let configs: Vec<(&str, &str, &str)> = vec![
|
||
("X_A", "Script", "-- xa"),
|
||
("X_B", "LocalScript", "-- xb"),
|
||
("X_C", "ModuleScript", "-- xc"),
|
||
("X_D", "Script", "-- xd"),
|
||
("X_E", "LocalScript", "-- xe"),
|
||
];
|
||
|
||
// Send all 5 as individual no-wait requests
|
||
let (session_id, _rs_id, instances) = get_stress_instances(&session);
|
||
for ((id, _), (name, class, source)) in instances.iter().zip(configs.iter()) {
|
||
send_update_no_wait(
|
||
&session,
|
||
&session_id,
|
||
make_combined_update(*id, Some(name), Some(class), Some(source)),
|
||
);
|
||
}
|
||
|
||
wait_for_settle();
|
||
for (name, class, source) in &configs {
|
||
verify_instance_file(&src, name, class, Some(source));
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Filesystem: rename + edit + delete + recreate + rename again, all on the
|
||
/// same file in rapid succession.
|
||
#[test]
|
||
#[cfg_attr(target_os = "macos", ignore)]
|
||
fn extreme_filesystem_chaos_single_file() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let file = src.join("existing.luau");
|
||
let gap = Duration::from_millis(STRESS_OP_DELAY_MS);
|
||
|
||
// Step 1: Edit
|
||
fs::write(&file, "-- chaos v1\nreturn {}").unwrap();
|
||
thread::sleep(gap);
|
||
|
||
// Step 2: Rename
|
||
let file2 = src.join("Chaos.luau");
|
||
fs::rename(&file, &file2).unwrap();
|
||
thread::sleep(gap);
|
||
|
||
// Step 3: Edit renamed file
|
||
fs::write(&file2, "-- chaos v2\nreturn {}").unwrap();
|
||
thread::sleep(gap);
|
||
|
||
// Step 4: Delete
|
||
fs::remove_file(&file2).unwrap();
|
||
thread::sleep(gap);
|
||
|
||
// Step 5: Recreate with original name
|
||
fs::write(&file, "-- chaos v3\nreturn {}").unwrap();
|
||
thread::sleep(gap);
|
||
|
||
// Step 6: Edit again
|
||
fs::write(&file, "-- chaos final\nreturn {}").unwrap();
|
||
|
||
poll_tree_source(
|
||
&session,
|
||
rs_id,
|
||
"existing",
|
||
"-- chaos final",
|
||
STRESS_POLL_TIMEOUT_MS,
|
||
);
|
||
});
|
||
}
|
||
|
||
/// Filesystem: 5 files undergoing simultaneous rename chains.
|
||
/// macOS kqueue drops events under this level of rename pressure (5 files ×
|
||
/// 5 rounds with 250ms gaps). The single-file variant
|
||
/// `watcher_filesystem_rename_chain_10x` is already ignored on macOS for the
|
||
/// same reason.
|
||
#[test]
|
||
#[cfg_attr(target_os = "macos", ignore)]
|
||
fn extreme_filesystem_5_file_rename_chains() {
|
||
run_serve_test("syncback_stress", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let names = ["Alpha", "Bravo", "Charlie", "Delta", "Echo"];
|
||
let mut current: Vec<String> = names.iter().map(|n| n.to_string()).collect();
|
||
|
||
// 5 rounds of renaming all 5 files
|
||
for round in 1..=5 {
|
||
let mut next = Vec::new();
|
||
for (j, name) in current.iter().enumerate() {
|
||
let new_name = format!("{}_r{}", names[j], round);
|
||
fs::rename(
|
||
src.join(format!("{}.luau", name)),
|
||
src.join(format!("{}.luau", new_name)),
|
||
)
|
||
.unwrap();
|
||
next.push(new_name);
|
||
}
|
||
current = next;
|
||
thread::sleep(Duration::from_millis(STRESS_OP_DELAY_MS));
|
||
}
|
||
|
||
// All final names should exist
|
||
for (j, _) in names.iter().enumerate() {
|
||
let final_name = format!("{}_r5", names[j]);
|
||
poll_tree_has_instance(&session, rs_id, &final_name, STRESS_POLL_TIMEOUT_MS);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ===========================================================================
|
||
// PART 4: Failed Operation Suppression Cleanup
|
||
//
|
||
// When the change_processor suppresses a VFS path before a filesystem
|
||
// operation (rename, write) and that operation fails, the suppression must be
|
||
// cleaned up. Otherwise a stale entry sits in the suppression map and
|
||
// silently swallows the next real VFS event for that path — e.g. an external
|
||
// edit in VS Code would be lost.
|
||
//
|
||
// Strategy: block the filesystem operation by placing a non-empty directory
|
||
// at the rename target (or making the file read-only for writes), then
|
||
// verify that subsequent external edits are still picked up by the watcher.
|
||
// ===========================================================================
|
||
|
||
/// When a standalone file rename fails (target blocked by directory),
|
||
/// subsequent external edits to the original file must still be detected.
|
||
#[test]
|
||
fn failed_rename_no_stale_suppression() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
// Block the rename target: create a non-empty directory at the path
|
||
// that fs::rename would target. fs::rename("existing.luau",
|
||
// "Blocked.luau") fails because "Blocked.luau" is a directory.
|
||
let blocker = src.join("Blocked.luau");
|
||
fs::create_dir(&blocker).unwrap();
|
||
fs::write(blocker.join("placeholder"), "").unwrap();
|
||
|
||
// Send a rename that will fail internally.
|
||
let (session_id, _rs_id, id) = get_rs_and_existing(&session);
|
||
send_update_fast(&session, &session_id, make_rename_update(id, "Blocked"));
|
||
wait_for_settle();
|
||
|
||
// existing.luau must still be on disk (rename failed).
|
||
assert_file_exists(
|
||
&src.join("existing.luau"),
|
||
"existing.luau should survive failed rename",
|
||
);
|
||
|
||
// Remove the blocker so it doesn't interfere.
|
||
fs::remove_dir_all(&blocker).unwrap();
|
||
|
||
// Edit existing.luau externally — this VFS event must NOT be
|
||
// suppressed. If the suppression leaked, this edit is silently lost.
|
||
fs::write(
|
||
src.join("existing.luau"),
|
||
"-- after failed rename\nreturn {}",
|
||
)
|
||
.unwrap();
|
||
|
||
// The re-snapshot should pick up the edit (and fix the DOM name back
|
||
// to "existing" since the file is still named existing.luau).
|
||
poll_tree_source(&session, rs_id, "existing", "-- after failed rename", 5000);
|
||
});
|
||
}
|
||
|
||
/// When a ClassName change rename fails (target blocked), subsequent
|
||
/// external edits to the original file must still be detected.
|
||
#[test]
|
||
fn failed_classchange_rename_no_stale_suppression() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
// ModuleScript→Script renames existing.luau → existing.server.luau.
|
||
// Block that target with a directory.
|
||
let blocker = src.join("existing.server.luau");
|
||
fs::create_dir(&blocker).unwrap();
|
||
fs::write(blocker.join("placeholder"), "").unwrap();
|
||
|
||
// Send ClassName change whose internal rename will fail.
|
||
let (session_id, _rs_id, id) = get_rs_and_existing(&session);
|
||
send_update_fast(&session, &session_id, make_class_update(id, "Script"));
|
||
wait_for_settle();
|
||
|
||
// Original file must still exist.
|
||
assert_file_exists(
|
||
&src.join("existing.luau"),
|
||
"existing.luau should survive failed class rename",
|
||
);
|
||
|
||
// Remove blocker.
|
||
fs::remove_dir_all(&blocker).unwrap();
|
||
|
||
// External edit — must not be suppressed.
|
||
fs::write(
|
||
src.join("existing.luau"),
|
||
"-- after failed classchange\nreturn {}",
|
||
)
|
||
.unwrap();
|
||
|
||
poll_tree_source(
|
||
&session,
|
||
rs_id,
|
||
"existing",
|
||
"-- after failed classchange",
|
||
5000,
|
||
);
|
||
});
|
||
}
|
||
|
||
/// When a directory rename fails (target blocked), subsequent external
|
||
/// edits to the init file must still be detected.
|
||
#[test]
|
||
fn failed_directory_rename_no_stale_suppression() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
// Block the rename target with a non-empty directory.
|
||
let blocker = src.join("BlockedDir");
|
||
fs::create_dir(&blocker).unwrap();
|
||
fs::write(blocker.join("placeholder"), "").unwrap();
|
||
|
||
// Send directory rename that will fail internally.
|
||
let (session_id, id) = get_format_transitions_instance(&session, "DirModuleWithChildren");
|
||
send_update_fast(&session, &session_id, make_rename_update(id, "BlockedDir"));
|
||
wait_for_settle();
|
||
|
||
// Original directory must still exist.
|
||
assert!(
|
||
src.join("DirModuleWithChildren").is_dir(),
|
||
"DirModuleWithChildren should survive failed rename"
|
||
);
|
||
|
||
// Remove blocker.
|
||
fs::remove_dir_all(&blocker).unwrap();
|
||
|
||
// Edit the init file externally — VFS must not suppress this.
|
||
fs::write(
|
||
src.join("DirModuleWithChildren").join("init.luau"),
|
||
"-- after failed dir rename\nreturn {}",
|
||
)
|
||
.unwrap();
|
||
|
||
poll_tree_source(
|
||
&session,
|
||
rs_id,
|
||
"DirModuleWithChildren",
|
||
"-- after failed dir rename",
|
||
5000,
|
||
);
|
||
});
|
||
}
|
||
|
||
/// When a Source write fails (read-only file), subsequent external edits
|
||
/// must still be detected after the file is made writable again.
|
||
#[test]
|
||
#[allow(clippy::permissions_set_readonly_false)]
|
||
fn failed_write_no_stale_suppression() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
let file = src.join("existing.luau");
|
||
|
||
// Make the file read-only so fs::write fails.
|
||
let mut perms = fs::metadata(&file).unwrap().permissions();
|
||
perms.set_readonly(true);
|
||
fs::set_permissions(&file, perms).unwrap();
|
||
|
||
// Send Source change that will fail internally.
|
||
let (session_id, _rs_id, id) = get_rs_and_existing(&session);
|
||
send_update_fast(
|
||
&session,
|
||
&session_id,
|
||
make_combined_update(id, None, None, Some("-- should fail to write")),
|
||
);
|
||
wait_for_settle();
|
||
|
||
// Restore write permission.
|
||
let mut perms = fs::metadata(&file).unwrap().permissions();
|
||
perms.set_readonly(false);
|
||
fs::set_permissions(&file, perms).unwrap();
|
||
|
||
// External edit — VFS event must not be suppressed.
|
||
fs::write(&file, "-- after failed write\nreturn {}").unwrap();
|
||
|
||
poll_tree_source(&session, rs_id, "existing", "-- after failed write", 5000);
|
||
});
|
||
}
|
||
|
||
/// After a failed rename, verify the instance can still be successfully
|
||
/// renamed on a retry (no permanently broken state).
|
||
#[test]
|
||
fn failed_rename_then_successful_rename() {
|
||
run_serve_test("syncback_write", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
// Block first rename attempt.
|
||
let blocker = src.join("FirstAttempt.luau");
|
||
fs::create_dir(&blocker).unwrap();
|
||
fs::write(blocker.join("placeholder"), "").unwrap();
|
||
|
||
let (session_id, _rs_id, id) = get_rs_and_existing(&session);
|
||
send_update_fast(
|
||
&session,
|
||
&session_id,
|
||
make_rename_update(id, "FirstAttempt"),
|
||
);
|
||
wait_for_settle();
|
||
|
||
// First rename failed — original file still here.
|
||
assert_file_exists(
|
||
&src.join("existing.luau"),
|
||
"existing.luau after blocked rename",
|
||
);
|
||
|
||
// Remove blocker.
|
||
fs::remove_dir_all(&blocker).unwrap();
|
||
|
||
// Edit the file to reset DOM name back to "existing" via VFS.
|
||
fs::write(
|
||
src.join("existing.luau"),
|
||
"-- reset after failure\nreturn {}",
|
||
)
|
||
.unwrap();
|
||
poll_tree_source(&session, rs_id, "existing", "-- reset after failure", 5000);
|
||
|
||
// Now retry with an unblocked target — should succeed.
|
||
let (session_id, _rs_id, id) = get_rs_and_existing(&session);
|
||
send_update_fast(
|
||
&session,
|
||
&session_id,
|
||
make_rename_update(id, "SuccessfulRetry"),
|
||
);
|
||
wait_for_settle();
|
||
|
||
assert_file_exists(
|
||
&src.join("SuccessfulRetry.luau"),
|
||
"SuccessfulRetry.luau after unblocked rename",
|
||
);
|
||
assert_not_exists(
|
||
&src.join("existing.luau"),
|
||
"existing.luau should be gone after successful rename",
|
||
);
|
||
});
|
||
}
|
||
|
||
/// After a failed directory rename, verify the directory can still be
|
||
/// renamed on retry.
|
||
#[test]
|
||
fn failed_directory_rename_then_successful_rename() {
|
||
run_serve_test("syncback_format_transitions", |session, _redactions| {
|
||
let src = session.path().join("src");
|
||
let rs_id = get_rs_id(&session);
|
||
|
||
// Block first rename.
|
||
let blocker = src.join("DirBlocked");
|
||
fs::create_dir(&blocker).unwrap();
|
||
fs::write(blocker.join("placeholder"), "").unwrap();
|
||
|
||
let (session_id, id) = get_format_transitions_instance(&session, "DirModuleWithChildren");
|
||
send_update_fast(&session, &session_id, make_rename_update(id, "DirBlocked"));
|
||
wait_for_settle();
|
||
|
||
// Original directory still exists.
|
||
assert!(
|
||
src.join("DirModuleWithChildren").is_dir(),
|
||
"DirModuleWithChildren should survive blocked rename"
|
||
);
|
||
|
||
// Remove blocker.
|
||
fs::remove_dir_all(&blocker).unwrap();
|
||
|
||
// Edit init to reset DOM name via VFS.
|
||
fs::write(
|
||
src.join("DirModuleWithChildren").join("init.luau"),
|
||
"-- dir reset\nreturn {}",
|
||
)
|
||
.unwrap();
|
||
poll_tree_source(
|
||
&session,
|
||
rs_id,
|
||
"DirModuleWithChildren",
|
||
"-- dir reset",
|
||
5000,
|
||
);
|
||
|
||
// Retry rename — should succeed now.
|
||
let (session_id, id) = get_format_transitions_instance(&session, "DirModuleWithChildren");
|
||
send_update_fast(
|
||
&session,
|
||
&session_id,
|
||
make_rename_update(id, "DirRetrySuccess"),
|
||
);
|
||
wait_for_settle();
|
||
|
||
assert!(
|
||
src.join("DirRetrySuccess").is_dir(),
|
||
"DirRetrySuccess should exist after unblocked rename"
|
||
);
|
||
assert_file_exists(
|
||
&src.join("DirRetrySuccess").join("init.luau"),
|
||
"init.luau in DirRetrySuccess",
|
||
);
|
||
assert_file_exists(
|
||
&src.join("DirRetrySuccess").join("ChildA.luau"),
|
||
"ChildA in DirRetrySuccess",
|
||
);
|
||
});
|
||
}
|