mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
Add comprehensive syncback roundtrip and clean mode stress tests
- Completed the implementation of roundtrip tests to validate the syncback process, ensuring that building a project, syncing back, and rebuilding produces semantically identical outputs. - Created a new utility module for roundtrip testing, including functions for running builds and asserting directory equality. - Developed clean mode stress tests to verify that syncing back to a dirty directory yields identical results to syncing back to a fresh directory, covering various mutation scenarios. - Updated existing test modules to incorporate new test cases and ensure consistency in handling standalone scripts transitioning to directory format when children are added.
This commit is contained in:
parent
17d250f26a
commit
4a66eff938
10 changed files with 1517 additions and 50 deletions
|
|
@ -1,4 +1,5 @@
|
|||
pub mod internable;
|
||||
pub mod io_util;
|
||||
pub mod roundtrip_util;
|
||||
pub mod serve_util;
|
||||
pub mod syncback_util;
|
||||
|
|
|
|||
348
tests/rojo_test/roundtrip_util.rs
Normal file
348
tests/rojo_test/roundtrip_util.rs
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
//! Utilities for roundtrip and stress testing of syncback.
|
||||
//!
|
||||
//! Provides helpers for:
|
||||
//! - Building projects to rbxl files
|
||||
//! - Running syncback in clean mode
|
||||
//! - Comparing directories for equality
|
||||
//! - Applying filesystem mutations for stress testing
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::io_util::ROJO_PATH;
|
||||
|
||||
/// Run `rojo build` on a project and return the output file path.
|
||||
///
|
||||
/// Returns a tuple of (TempDir handle, output path). The TempDir must be kept
|
||||
/// alive for the output file to remain accessible.
|
||||
pub fn run_rojo_build(project_path: &Path, output_name: &str) -> (TempDir, PathBuf) {
|
||||
let output_dir = tempfile::tempdir().expect("Failed to create temp dir for build output");
|
||||
let output_path = output_dir.path().join(output_name);
|
||||
|
||||
let output = Command::new(ROJO_PATH)
|
||||
.args([
|
||||
"build",
|
||||
project_path.to_str().unwrap(),
|
||||
"-o",
|
||||
output_path.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.expect("Failed to run rojo build");
|
||||
|
||||
if !output.status.success() {
|
||||
panic!(
|
||||
"rojo build failed for {:?}:\nstdout: {}\nstderr: {}",
|
||||
project_path,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
(output_dir, output_path)
|
||||
}
|
||||
|
||||
/// Run `rojo syncback` in clean mode (default behavior).
|
||||
///
|
||||
/// Returns true if syncback succeeded, false otherwise.
|
||||
pub fn run_rojo_syncback_clean(project_path: &Path, input_path: &Path) -> bool {
|
||||
let output = Command::new(ROJO_PATH)
|
||||
.args([
|
||||
"syncback",
|
||||
project_path.to_str().unwrap(),
|
||||
"--input",
|
||||
input_path.to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.expect("Failed to run rojo syncback");
|
||||
|
||||
if !output.status.success() {
|
||||
eprintln!(
|
||||
"rojo syncback failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
output.status.success()
|
||||
}
|
||||
|
||||
/// Compare two directories recursively, asserting they have identical content.
|
||||
///
|
||||
/// This compares:
|
||||
/// - The set of files (by relative path)
|
||||
/// - The content of each file (byte-for-byte)
|
||||
///
|
||||
/// Panics with a detailed message if the directories differ.
|
||||
pub fn assert_dirs_equal(dir_a: &Path, dir_b: &Path) {
|
||||
let files_a = collect_files(dir_a);
|
||||
let files_b = collect_files(dir_b);
|
||||
|
||||
// Compare file sets
|
||||
let set_a: HashSet<_> = files_a.keys().collect();
|
||||
let set_b: HashSet<_> = files_b.keys().collect();
|
||||
|
||||
let only_in_a: Vec<_> = set_a.difference(&set_b).collect();
|
||||
let only_in_b: Vec<_> = set_b.difference(&set_a).collect();
|
||||
|
||||
if !only_in_a.is_empty() || !only_in_b.is_empty() {
|
||||
panic!(
|
||||
"Directory contents differ:\n Only in {:?}: {:?}\n Only in {:?}: {:?}",
|
||||
dir_a, only_in_a, dir_b, only_in_b
|
||||
);
|
||||
}
|
||||
|
||||
// Compare file contents
|
||||
for (rel_path, content_a) in &files_a {
|
||||
let content_b = &files_b[rel_path];
|
||||
if content_a != content_b {
|
||||
// Try to show as text if possible for better error messages
|
||||
let text_a = String::from_utf8_lossy(content_a);
|
||||
let text_b = String::from_utf8_lossy(content_b);
|
||||
panic!(
|
||||
"File contents differ for {:?}:\n--- {:?}\n{}\n--- {:?}\n{}",
|
||||
rel_path, dir_a, text_a, dir_b, text_b
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all files in a directory recursively.
|
||||
///
|
||||
/// Returns a map of relative paths to file contents.
|
||||
fn collect_files(dir: &Path) -> HashMap<PathBuf, Vec<u8>> {
|
||||
let mut result = HashMap::new();
|
||||
if dir.exists() {
|
||||
collect_files_recursive(dir, dir, &mut result);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn collect_files_recursive(base: &Path, current: &Path, result: &mut HashMap<PathBuf, Vec<u8>>) {
|
||||
let entries = match fs::read_dir(current) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to read directory {:?}: {}", current, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to read directory entry: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let path = entry.path();
|
||||
let rel_path = path
|
||||
.strip_prefix(base)
|
||||
.expect("Path should be under base")
|
||||
.to_path_buf();
|
||||
|
||||
if path.is_dir() {
|
||||
collect_files_recursive(base, &path, result);
|
||||
} else {
|
||||
match fs::read(&path) {
|
||||
Ok(content) => {
|
||||
result.insert(rel_path, content);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to read file {:?}: {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutation types for stress testing clean mode.
|
||||
///
|
||||
/// Each mutation represents a way to "dirty" the filesystem that clean mode
|
||||
/// should be able to fix.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Mutation {
|
||||
/// Add an orphan file that doesn't exist in the rbxl
|
||||
AddOrphanFile {
|
||||
relative_path: &'static str,
|
||||
content: &'static str,
|
||||
},
|
||||
/// Delete a file that exists in the rbxl
|
||||
DeleteFile { relative_path: &'static str },
|
||||
/// Rename a file (creates orphan + missing file)
|
||||
RenameFile {
|
||||
from: &'static str,
|
||||
to: &'static str,
|
||||
},
|
||||
/// Change file extension (e.g., .luau -> .modulescript)
|
||||
ChangeExtension {
|
||||
from: &'static str,
|
||||
to: &'static str,
|
||||
},
|
||||
/// Add an orphan directory with content
|
||||
AddOrphanDirectory { relative_path: &'static str },
|
||||
/// Delete an entire directory
|
||||
DeleteDirectory { relative_path: &'static str },
|
||||
/// Convert directory format to standalone file
|
||||
ConvertDirToFile {
|
||||
dir: &'static str,
|
||||
file_content: &'static str,
|
||||
},
|
||||
/// Convert standalone file to directory format
|
||||
ConvertFileToDir { file: &'static str },
|
||||
/// Corrupt a .meta.json5 file
|
||||
CorruptMetaFile { relative_path: &'static str },
|
||||
/// Modify file content to be wrong
|
||||
ModifyFileContent {
|
||||
relative_path: &'static str,
|
||||
new_content: &'static str,
|
||||
},
|
||||
/// Add a spurious .project.json5 file
|
||||
AddNestedProjectFile { relative_path: &'static str },
|
||||
/// Create duplicate with different extension
|
||||
DuplicateWithDifferentExtension {
|
||||
original: &'static str,
|
||||
duplicate_ext: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
/// Apply a mutation to a directory.
|
||||
///
|
||||
/// This modifies the filesystem to simulate various "dirty" states that clean
|
||||
/// mode syncback should be able to fix.
|
||||
pub fn apply_mutation(dir: &Path, mutation: &Mutation) {
|
||||
match mutation {
|
||||
Mutation::AddOrphanFile {
|
||||
relative_path,
|
||||
content,
|
||||
} => {
|
||||
let path = dir.join(relative_path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).ok();
|
||||
}
|
||||
fs::write(&path, content).expect("Failed to write orphan file");
|
||||
}
|
||||
Mutation::DeleteFile { relative_path } => {
|
||||
let path = dir.join(relative_path);
|
||||
if path.exists() {
|
||||
fs::remove_file(&path).expect("Failed to delete file");
|
||||
}
|
||||
}
|
||||
Mutation::RenameFile { from, to } => {
|
||||
let from_path = dir.join(from);
|
||||
let to_path = dir.join(to);
|
||||
if let Some(parent) = to_path.parent() {
|
||||
fs::create_dir_all(parent).ok();
|
||||
}
|
||||
if from_path.exists() {
|
||||
fs::rename(&from_path, &to_path).expect("Failed to rename file");
|
||||
}
|
||||
}
|
||||
Mutation::ChangeExtension { from, to } => {
|
||||
let from_path = dir.join(from);
|
||||
let to_path = dir.join(to);
|
||||
if from_path.exists() {
|
||||
fs::rename(&from_path, &to_path).expect("Failed to change extension");
|
||||
}
|
||||
}
|
||||
Mutation::AddOrphanDirectory { relative_path } => {
|
||||
let path = dir.join(relative_path);
|
||||
fs::create_dir_all(&path).expect("Failed to create orphan directory");
|
||||
// Add a file so the directory isn't empty
|
||||
fs::write(path.join("orphan_child.luau"), "-- orphan child").ok();
|
||||
}
|
||||
Mutation::DeleteDirectory { relative_path } => {
|
||||
let path = dir.join(relative_path);
|
||||
if path.exists() {
|
||||
fs::remove_dir_all(&path).expect("Failed to delete directory");
|
||||
}
|
||||
}
|
||||
Mutation::ConvertDirToFile { dir: dir_path, file_content } => {
|
||||
let path = dir.join(dir_path);
|
||||
if path.exists() && path.is_dir() {
|
||||
fs::remove_dir_all(&path).expect("Failed to remove directory");
|
||||
}
|
||||
// Create standalone file with same base name + .luau extension
|
||||
let file_path = path.with_extension("luau");
|
||||
fs::write(&file_path, file_content).expect("Failed to write file");
|
||||
}
|
||||
Mutation::ConvertFileToDir { file } => {
|
||||
let file_path = dir.join(file);
|
||||
let content = fs::read_to_string(&file_path).unwrap_or_default();
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path).expect("Failed to remove file");
|
||||
}
|
||||
// Create directory with init file
|
||||
let dir_path = file_path.with_extension("");
|
||||
fs::create_dir_all(&dir_path).expect("Failed to create directory");
|
||||
fs::write(dir_path.join("init.luau"), content).expect("Failed to write init");
|
||||
}
|
||||
Mutation::CorruptMetaFile { relative_path } => {
|
||||
let path = dir.join(relative_path);
|
||||
fs::write(&path, "{ this is not valid json5 {{{{")
|
||||
.expect("Failed to corrupt meta file");
|
||||
}
|
||||
Mutation::ModifyFileContent {
|
||||
relative_path,
|
||||
new_content,
|
||||
} => {
|
||||
let path = dir.join(relative_path);
|
||||
fs::write(&path, new_content).expect("Failed to modify content");
|
||||
}
|
||||
Mutation::AddNestedProjectFile { relative_path } => {
|
||||
let path = dir.join(relative_path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).ok();
|
||||
}
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{ "name": "SpuriousProject", "tree": { "$className": "Folder" } }"#,
|
||||
)
|
||||
.expect("Failed to add nested project");
|
||||
}
|
||||
Mutation::DuplicateWithDifferentExtension {
|
||||
original,
|
||||
duplicate_ext,
|
||||
} => {
|
||||
let orig_path = dir.join(original);
|
||||
let content = fs::read(&orig_path).unwrap_or_default();
|
||||
let dup_path = orig_path.with_extension(duplicate_ext);
|
||||
fs::write(&dup_path, content).expect("Failed to create duplicate");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the full project directory from source to destination.
|
||||
///
|
||||
/// This copies the entire directory structure, which is required because
|
||||
/// syncback needs the `$path` directories to exist for building the "old tree".
|
||||
pub fn copy_project_dir(src: &Path, dst: &Path) {
|
||||
copy_dir_recursive(src, dst).expect("Failed to copy project directory");
|
||||
}
|
||||
|
||||
/// Recursively copy a directory.
|
||||
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
if !dst.exists() {
|
||||
fs::create_dir_all(dst)?;
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let dest_path = dst.join(entry.file_name());
|
||||
|
||||
if path.is_dir() {
|
||||
copy_dir_recursive(&path, &dest_path)?;
|
||||
} else {
|
||||
fs::copy(&path, &dest_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue