mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
- Marked all todos in the stress test plan as completed, including the implementation of timing helpers, a PRNG, and various fuzz tests for two-way sync operations. - Introduced a new `syncback_stress` fixture with a project configuration and multiple Luau modules (Alpha, Bravo, Charlie, Delta, Echo) to support stress testing. - Enhanced the `api.rs` file with improved path handling logic for better integration with the stress testing framework.
3258 lines
116 KiB
Rust
3258 lines
116 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;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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()
|
|
);
|
|
}
|
|
|
|
/// 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,
|
|
},
|
|
);
|
|
|
|
let updated = fs::read_to_string(&file_path).unwrap();
|
|
assert!(
|
|
updated.contains("Updated via two-way sync"),
|
|
"File should contain new Source, got: {}",
|
|
updated
|
|
);
|
|
});
|
|
}
|
|
|
|
/// 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,
|
|
},
|
|
);
|
|
|
|
assert_not_exists(&old_path, "Old file after rename");
|
|
assert_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");
|
|
|
|
assert_not_exists(
|
|
&session.path().join("src").join("existing.luau"),
|
|
"Old script gone",
|
|
);
|
|
assert_not_exists(&old_meta, "Old meta gone");
|
|
assert_file_exists(&new_script, "Renamed script exists");
|
|
assert_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,
|
|
},
|
|
);
|
|
|
|
assert_not_exists(&module_path, "Module file after class change");
|
|
assert_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,
|
|
},
|
|
);
|
|
|
|
assert_not_exists(&module_path, "Module file after class change");
|
|
assert_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");
|
|
let content = fs::read_to_string(&file).unwrap();
|
|
assert!(content.contains("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,
|
|
},
|
|
);
|
|
|
|
assert_not_exists(&file, "Step 2: old file gone");
|
|
let new_file = src.join("GameService.luau");
|
|
assert_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,
|
|
},
|
|
);
|
|
|
|
assert!(
|
|
!old_dir.exists(),
|
|
"Old directory should be gone after rename: {}",
|
|
old_dir.display()
|
|
);
|
|
assert!(
|
|
new_dir.is_dir(),
|
|
"New directory should exist after rename: {}",
|
|
new_dir.display()
|
|
);
|
|
|
|
// 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,
|
|
},
|
|
);
|
|
|
|
assert_not_exists(&old_init, "init.luau after class change to Script");
|
|
assert_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,
|
|
},
|
|
);
|
|
|
|
assert_not_exists(&old_path, "Script file after class change to ModuleScript");
|
|
assert_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,
|
|
},
|
|
);
|
|
|
|
assert_not_exists(&old_path, "Script file after class change to LocalScript");
|
|
assert_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,
|
|
},
|
|
);
|
|
|
|
assert_not_exists(&old_path, "LocalScript file after class change to Script");
|
|
assert_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,
|
|
},
|
|
);
|
|
|
|
assert_not_exists(&old_path, "Old file after combined rename+source");
|
|
assert_file_exists(&new_path, "Renamed file after combined rename+source");
|
|
|
|
let content = fs::read_to_string(&new_path).unwrap();
|
|
assert!(
|
|
content.contains("Written after rename"),
|
|
"Source should be written to the NEW file location, got: {}",
|
|
content
|
|
);
|
|
});
|
|
}
|
|
|
|
/// 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,
|
|
},
|
|
);
|
|
|
|
assert_not_exists(&old_path, "ModuleScript file after ClassName+Source");
|
|
assert_file_exists(&new_path, "Script file after ClassName+Source");
|
|
|
|
let content = fs::read_to_string(&new_path).unwrap();
|
|
assert!(
|
|
content.contains("Source after class change"),
|
|
"Source should be in the new file, got: {}",
|
|
content
|
|
);
|
|
});
|
|
}
|
|
|
|
/// 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,
|
|
},
|
|
);
|
|
|
|
assert_not_exists(&old_path, "Original file after combined rename+classname");
|
|
assert_not_exists(
|
|
&renamed_only,
|
|
"Renamed.luau should not exist — ClassName handler should have \
|
|
applied the .server extension",
|
|
);
|
|
assert_file_exists(
|
|
&final_path,
|
|
"Renamed.server.luau should exist after combined rename+classname",
|
|
);
|
|
|
|
let content = fs::read_to_string(&final_path).unwrap();
|
|
assert!(
|
|
content.contains("After rename + class change"),
|
|
"Source should be in the final file, got: {}",
|
|
content
|
|
);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
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(200));
|
|
}
|
|
|
|
wait_for_settle();
|
|
|
|
// 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 {}",
|
|
);
|
|
|
|
// 20 random operations in a single continuous chain (no reset)
|
|
for op_idx in 0..20 {
|
|
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 20 fuzz ops",
|
|
expected.display()
|
|
);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 {}",
|
|
);
|
|
|
|
// 20 random combined operations in a single continuous chain
|
|
for op_idx in 0..20 {
|
|
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 20 combined fuzz ops. State: name={}, class={}",
|
|
expected.display(),
|
|
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]
|
|
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(30));
|
|
}
|
|
|
|
poll_tree_has_instance(&session, rs_id, "R10", 3000);
|
|
|
|
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,
|
|
);
|
|
}
|
|
});
|
|
}
|