mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-25 04:54:07 +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
|
|
@ -41,6 +41,21 @@ todos:
|
||||||
- id: integration-twoway
|
- id: integration-twoway
|
||||||
content: Create twoWaySync.spec.lua with 15+ tests for bidirectional sync and conflicts
|
content: Create twoWaySync.spec.lua with 15+ tests for bidirectional sync and conflicts
|
||||||
status: pending
|
status: pending
|
||||||
|
- id: scripts-only-mode
|
||||||
|
content: Create scriptsOnly.spec.lua with tests for scripts-only mode edge cases
|
||||||
|
status: pending
|
||||||
|
- id: everything-mode
|
||||||
|
content: Create everythingMode.spec.lua with tests for everything mode and all instance types
|
||||||
|
status: pending
|
||||||
|
- id: mode-transitions
|
||||||
|
content: Create modeTransitions.spec.lua with tests for switching between sync modes
|
||||||
|
status: pending
|
||||||
|
- id: multi-developer
|
||||||
|
content: Create multiDeveloper.spec.lua simulating concurrent changes from multiple sources
|
||||||
|
status: pending
|
||||||
|
- id: race-conditions
|
||||||
|
content: Create raceConditions.spec.lua detecting race conditions during sync
|
||||||
|
status: pending
|
||||||
- id: chaos-tests
|
- id: chaos-tests
|
||||||
content: Create stressPatterns.spec.lua with 20+ chaos engineering tests
|
content: Create stressPatterns.spec.lua with 20+ chaos engineering tests
|
||||||
status: pending
|
status: pending
|
||||||
|
|
@ -52,14 +67,81 @@ isProject: false
|
||||||
|
|
||||||
# Comprehensive Lua Plugin Stress Tests
|
# Comprehensive Lua Plugin Stress Tests
|
||||||
|
|
||||||
|
## Vision and Philosophy
|
||||||
|
|
||||||
|
**Goal**: Make the plugin/API interface bulletproof for complex games. The plugin must handle abuse from real-world scenarios - huge games, many developers, concurrent Rojo setups, and chaotic Studio environments.
|
||||||
|
|
||||||
|
**Testing Philosophy**:
|
||||||
|
|
||||||
|
- Tests exist to EXPOSE limitations, not just pass
|
||||||
|
- Tests that break the system are GOOD - they're the forcing function for quality
|
||||||
|
- Do NOT back down when tests fail - fix the underlying issue
|
||||||
|
- A failing stress test reveals a real bug that will hit users in production
|
||||||
|
|
||||||
|
**Code Quality Awareness**:
|
||||||
|
|
||||||
|
- The plugin code has been iterated by AI agents and may have accumulated "fix on fix" complexity
|
||||||
|
- If a subsystem needs repeated patches, step back and consider a ground-up redesign
|
||||||
|
- Example: if diff equality checks keep needing fixes, redesign the comparison logic
|
||||||
|
- Use good judgment - don't rewrite everything, but don't just pile on fixes either
|
||||||
|
|
||||||
|
**Why Abuse Testing Matters**:
|
||||||
|
|
||||||
|
- The plugin/API interface is a mega weak layer
|
||||||
|
- It constantly crashes, nukes files, syncs incorrectly, misses files, or syncs wrong files
|
||||||
|
- Real games have thousands of instances, concurrent developers, and unpredictable Studio behavior
|
||||||
|
- Scripts-only mode AND everything-mode must both be bulletproof
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Motivation
|
## Motivation
|
||||||
|
|
||||||
The Rojo plugin/API interface is a critical weak layer that must handle:
|
The Rojo plugin/API interface is a critical weak layer that must handle:
|
||||||
|
|
||||||
- Scripts-only mode and everything-mode
|
- Scripts-only mode and everything-mode
|
||||||
- Large games with many developers
|
- Large games with many developers (potentially 10+ developers with their own Rojo setups)
|
||||||
- Concurrent Rojo setups
|
- Concurrent Rojo setups on the same game
|
||||||
- Edge cases causing crashes, file nuking, incorrect syncing
|
- Edge cases causing crashes, file nuking, incorrect syncing
|
||||||
|
- Rapid changes during active development
|
||||||
|
- Recovery from partial failures and network issues
|
||||||
|
|
||||||
|
## Known Bug Patterns to Catch
|
||||||
|
|
||||||
|
These are the types of bugs that stress tests should expose:
|
||||||
|
|
||||||
|
**Duplicate/Ambiguous Path Handling**:
|
||||||
|
- Instances with duplicate-named siblings cause ambiguous paths
|
||||||
|
- Nested duplicates at any level of the hierarchy
|
||||||
|
- Path uniqueness checks must walk entire ancestor chain
|
||||||
|
- Tests should verify duplicate detection at every level
|
||||||
|
|
||||||
|
**Property Equality Edge Cases**:
|
||||||
|
- Floating-point comparison (epsilon = 0.0001)
|
||||||
|
- Color3 RGB integer comparison vs float comparison
|
||||||
|
- CFrame/Vector3/Vector2 precision
|
||||||
|
- NaN handling (NaN != NaN)
|
||||||
|
- EnumItem vs number comparisons
|
||||||
|
|
||||||
|
**Patch Application Failures**:
|
||||||
|
- Partial application (some properties succeed, others fail)
|
||||||
|
- Ref properties to non-existent instances
|
||||||
|
- ClassName changes with child migration failures
|
||||||
|
- Instance creation failures (invalid ClassName)
|
||||||
|
- Permission errors on protected properties
|
||||||
|
|
||||||
|
**Concurrency Issues**:
|
||||||
|
- Changes during confirmation dialogue
|
||||||
|
- Rapid bidirectional changes
|
||||||
|
- Instance destruction during patch application
|
||||||
|
- WebSocket messages during patch processing
|
||||||
|
|
||||||
|
**Memory and Scale**:
|
||||||
|
- Large instance trees (1000+ instances)
|
||||||
|
- Deep hierarchies (50+ levels)
|
||||||
|
- Wide hierarchies (100+ siblings)
|
||||||
|
- Large patches (500+ changes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Test Files to Create
|
## Test Files to Create
|
||||||
|
|
||||||
|
|
@ -238,7 +320,80 @@ The Rojo plugin/API interface is a critical weak layer that must handle:
|
||||||
-- ChangeBatcher pause/resume during confirm
|
-- ChangeBatcher pause/resume during confirm
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Edge Case and Chaos Tests
|
### 6. Scripts-Only vs Everything Mode Tests
|
||||||
|
|
||||||
|
**`plugin/src/modes/scriptsOnly.spec.lua`** - Scripts-only mode specific tests
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Key test scenarios:
|
||||||
|
-- Only scripts are synced (ModuleScript, Script, LocalScript)
|
||||||
|
-- Non-script instances are preserved in Studio (not deleted)
|
||||||
|
-- Script children of non-script parents sync correctly
|
||||||
|
-- RunContext property handling for Script class
|
||||||
|
-- Source property encoding/decoding
|
||||||
|
-- ignoreUnknownInstances behavior with non-scripts
|
||||||
|
-- Nested scripts under non-script containers
|
||||||
|
-- Script deletion rules (scripts can always be deleted)
|
||||||
|
-- Format transitions (standalone <-> directory) for scripts only
|
||||||
|
```
|
||||||
|
|
||||||
|
**`plugin/src/modes/everythingMode.spec.lua`** - Everything mode specific tests
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Key test scenarios:
|
||||||
|
-- All instance types sync correctly
|
||||||
|
-- Folder creation and deletion
|
||||||
|
-- Part, Model, and other non-script instances
|
||||||
|
-- Instance properties beyond Source (Anchored, Size, Color, etc.)
|
||||||
|
-- Attributes on all instance types
|
||||||
|
-- Tags on all instance types
|
||||||
|
-- .model.json generation for complex instances
|
||||||
|
-- Directory structure for instances with children
|
||||||
|
-- Meta file generation (init.meta.json5)
|
||||||
|
-- Mixed hierarchies (scripts and non-scripts)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`plugin/src/modes/modeTransitions.spec.lua`** - Switching between modes
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Key test scenarios:
|
||||||
|
-- Switch from scripts-only to everything mode
|
||||||
|
-- Switch from everything mode to scripts-only
|
||||||
|
-- Files created in everything mode preserved when switching to scripts-only
|
||||||
|
-- Orphan non-script files in scripts-only mode
|
||||||
|
-- State consistency after mode switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Multi-Developer and Concurrent Scenarios
|
||||||
|
|
||||||
|
**`plugin/src/concurrent/multiDeveloper.spec.lua`** - Simulating multiple developers
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Key test scenarios:
|
||||||
|
-- Rapid changes from "multiple sources" (simulated)
|
||||||
|
-- Conflicting changes to same instance
|
||||||
|
-- Interleaved add/remove operations
|
||||||
|
-- Property changes while confirmation dialogue is open
|
||||||
|
-- Patches arriving during patch application
|
||||||
|
-- WebSocket message queue overflow scenarios
|
||||||
|
-- State recovery after connection drop
|
||||||
|
-- Merge conflicts in PatchSet
|
||||||
|
```
|
||||||
|
|
||||||
|
**`plugin/src/concurrent/raceConditions.spec.lua`** - Race condition detection
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Key test scenarios:
|
||||||
|
-- Instance destroyed during property read
|
||||||
|
-- Instance reparented during diff
|
||||||
|
-- InstanceMap modified during iteration
|
||||||
|
-- ChangeBatcher cycle during pause/resume
|
||||||
|
-- Confirmation callback during state transition
|
||||||
|
-- Multiple simultaneous patch applications
|
||||||
|
-- ID collision during rapid add/remove
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Edge Case and Chaos Tests
|
||||||
|
|
||||||
`**plugin/src/chaos/stressPatterns.spec.lua**` - Chaos engineering patterns
|
`**plugin/src/chaos/stressPatterns.spec.lua**` - Chaos engineering patterns
|
||||||
|
|
||||||
|
|
@ -297,31 +452,60 @@ The Rojo plugin/API interface is a critical weak layer that must handle:
|
||||||
## Test Categories Summary
|
## Test Categories Summary
|
||||||
|
|
||||||
|
|
||||||
| Category | File | Test Count (Est.) | Purpose |
|
| Category | File | Test Count (Est.) | Purpose |
|
||||||
| ------------------ | ------------------------------ | ----------------- | --------------------------------- |
|
| ------------------ | ------------------------------ | ----------------- | --------------------------------------- |
|
||||||
| Diff Stress | diff.stress.spec.lua | 25+ | Large trees, duplicates, equality |
|
| Diff Stress | diff.stress.spec.lua | 25+ | Large trees, duplicates, equality |
|
||||||
| Apply Stress | applyPatch.stress.spec.lua | 20+ | Partial failures, refs, className |
|
| Apply Stress | applyPatch.stress.spec.lua | 20+ | Partial failures, refs, className |
|
||||||
| Reify Stress | reify.stress.spec.lua | 15+ | Deep/wide trees, properties |
|
| Reify Stress | reify.stress.spec.lua | 15+ | Deep/wide trees, properties |
|
||||||
| Property Read | getProperty.spec.lua | 15+ | All error paths, types |
|
| Property Read | getProperty.spec.lua | 15+ | All error paths, types |
|
||||||
| Property Write | setProperty.spec.lua | 15+ | All error paths, coercion |
|
| Property Write | setProperty.spec.lua | 15+ | All error paths, coercion |
|
||||||
| Decode Values | decodeValue.spec.lua | 15+ | Refs, types, failures |
|
| Decode Values | decodeValue.spec.lua | 15+ | Refs, types, failures |
|
||||||
| Encode Instance | encodeInstance.stress.spec.lua | 25+ | Hierarchies, duplicates, types |
|
| Encode Instance | encodeInstance.stress.spec.lua | 25+ | Hierarchies, duplicates, types |
|
||||||
| Encode Property | encodeProperty.spec.lua | 20+ | All property types |
|
| Encode Property | encodeProperty.spec.lua | 20+ | All property types |
|
||||||
| PatchSet Stress | PatchSet.stress.spec.lua | 15+ | Merge, scale, conflict |
|
| PatchSet Stress | PatchSet.stress.spec.lua | 15+ | Merge, scale, conflict |
|
||||||
| InstanceMap Stress | InstanceMap.stress.spec.lua | 10+ | Scale, concurrency |
|
| InstanceMap Stress | InstanceMap.stress.spec.lua | 10+ | Scale, concurrency |
|
||||||
| Sync Flow | syncflow.spec.lua | 15+ | E2E, modes, recovery |
|
| Sync Flow | syncflow.spec.lua | 15+ | E2E, modes, recovery |
|
||||||
| Two-Way Sync | twoWaySync.spec.lua | 15+ | Bidirectional, conflicts |
|
| Two-Way Sync | twoWaySync.spec.lua | 15+ | Bidirectional, conflicts |
|
||||||
| Chaos Tests | stressPatterns.spec.lua | 20+ | Random ops, flapping |
|
| Scripts-Only Mode | scriptsOnly.spec.lua | 15+ | Script-specific sync, ignoreUnknown |
|
||||||
|
| Everything Mode | everythingMode.spec.lua | 20+ | All instance types, properties |
|
||||||
|
| Mode Transitions | modeTransitions.spec.lua | 10+ | Switching modes, orphan handling |
|
||||||
|
| Multi-Developer | multiDeveloper.spec.lua | 15+ | Concurrent changes, conflicts |
|
||||||
|
| Race Conditions | raceConditions.spec.lua | 15+ | Instance destruction during ops |
|
||||||
|
| Chaos Tests | stressPatterns.spec.lua | 20+ | Random ops, flapping, chaos engineering |
|
||||||
|
|
||||||
|
|
||||||
**Estimated Total: 225+ new tests**
|
**Estimated Total: 300+ new tests**
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
1. All tests pass in CI
|
**Primary Goal: Expose Limitations**
|
||||||
|
|
||||||
|
1. Tests should initially FAIL if the underlying code has bugs - this is expected and good
|
||||||
|
2. Each failing test should be tracked as a bug to fix, not a test to skip
|
||||||
|
3. Tests that pass immediately may not be stressing the system enough
|
||||||
|
|
||||||
|
**Quality Gates**:
|
||||||
|
|
||||||
|
1. All tests eventually pass in CI (after bugs are fixed)
|
||||||
2. Tests catch regressions in the critical paths identified
|
2. Tests catch regressions in the critical paths identified
|
||||||
3. Edge cases like duplicate names, ambiguous paths are thoroughly covered
|
3. Edge cases like duplicate names, ambiguous paths are thoroughly covered
|
||||||
4. Both scripts-only and everything modes have dedicated test coverage
|
4. Both scripts-only and everything modes have dedicated test coverage
|
||||||
5. Large-scale scenarios (1000+ instances) run without timeouts
|
5. Large-scale scenarios (1000+ instances) run without timeouts
|
||||||
6. Memory usage is reasonable during stress tests
|
6. Memory usage is reasonable during stress tests
|
||||||
|
7. No "fix on fix" pattern - if a test keeps failing after patches, redesign the subsystem
|
||||||
|
|
||||||
|
**Bug Tracking**:
|
||||||
|
|
||||||
|
When a stress test fails, document:
|
||||||
|
- The symptom (what failed)
|
||||||
|
- The root cause (after investigation)
|
||||||
|
- Whether it needs a patch or a redesign
|
||||||
|
- Link to the fix
|
||||||
|
|
||||||
|
## Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- Skipping tests because they're "too hard to fix"
|
||||||
|
- Adding workarounds instead of fixing root causes
|
||||||
|
- Reducing test scope to make tests pass
|
||||||
|
- Ignoring intermittent failures (they reveal real race conditions)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,64 @@ overview: Add comprehensive build-syncback roundtrip tests and mutation stress t
|
||||||
todos:
|
todos:
|
||||||
- id: roundtrip-utils
|
- id: roundtrip-utils
|
||||||
content: "Create `tests/rojo_test/roundtrip_util.rs` with helpers: `run_rojo_build`, `assert_dirs_equal`, `Mutation` enum and `apply_mutation`"
|
content: "Create `tests/rojo_test/roundtrip_util.rs` with helpers: `run_rojo_build`, `assert_dirs_equal`, `Mutation` enum and `apply_mutation`"
|
||||||
status: pending
|
status: completed
|
||||||
- id: roundtrip-tests
|
- id: roundtrip-tests
|
||||||
content: Create `tests/tests/syncback_roundtrip.rs` with build -> syncback -> rebuild roundtrip tests using existing build-test projects
|
content: Create `tests/tests/syncback_roundtrip.rs` with build -> syncback -> rebuild roundtrip tests using existing build-test projects
|
||||||
status: pending
|
status: completed
|
||||||
- id: clean-stress-tests
|
- id: clean-stress-tests
|
||||||
content: Create `tests/tests/clean_mode_stress.rs` with clean-equals-fresh tests covering 12+ mutation scenarios
|
content: Create `tests/tests/clean_mode_stress.rs` with clean-equals-fresh tests covering 12+ mutation scenarios
|
||||||
status: pending
|
status: completed
|
||||||
- id: wire-modules
|
- id: wire-modules
|
||||||
content: Update `tests/rojo_test/mod.rs` and `tests/tests/mod.rs` to include new test modules
|
content: Update `tests/rojo_test/mod.rs` and `tests/tests/mod.rs` to include new test modules
|
||||||
status: pending
|
status: in_progress
|
||||||
isProject: false
|
isProject: false
|
||||||
---
|
---
|
||||||
|
|
||||||
# Syncback Roundtrip and Clean Mode Stress Tests
|
# Syncback Roundtrip and Clean Mode Stress Tests
|
||||||
|
|
||||||
|
## Vision and Philosophy
|
||||||
|
|
||||||
|
**Goal**: Make syncback bulletproof for complex games. No filesystem inconsistencies. The filesystem must be the source of truth.
|
||||||
|
|
||||||
|
**Testing Philosophy**:
|
||||||
|
|
||||||
|
- Tests exist to EXPOSE limitations, not just pass
|
||||||
|
- Tests that break the system are GOOD - they're the forcing function for quality
|
||||||
|
- Do NOT back down when tests fail - fix the underlying issue
|
||||||
|
|
||||||
|
**Code Quality Concerns**:
|
||||||
|
|
||||||
|
- The syncback code has been iterated by AI agents and may have accumulated "fix on fix" complexity
|
||||||
|
- If a subsystem needs repeated patches, step back and consider a ground-up redesign
|
||||||
|
- Example: the orphan file detection algorithm may need rethinking rather than more patches
|
||||||
|
- Use good judgment - don't rewrite everything, but don't just pile on fixes either
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Status: Bugs Found by Stress Tests
|
||||||
|
|
||||||
|
### Bug 1: Nested Orphan Files Not Removed (ACTIVE)
|
||||||
|
|
||||||
|
**Symptom**: Orphan files at the top level (`src/orphan.txt`) are removed, but nested orphan files (`src/level-1/orphan.luau`) are NOT removed.
|
||||||
|
|
||||||
|
**Debug output**: `Scanned 2 existing paths from filesystem` - but should be finding many more paths including nested orphan files.
|
||||||
|
|
||||||
|
**Root cause investigation needed**: The orphan scanning algorithm in `src/syncback/mod.rs` lines 220-292 may have a bug in:
|
||||||
|
|
||||||
|
1. How `dirs_to_scan` is populated from root children
|
||||||
|
2. How the recursive `scan_directory` function traverses the filesystem
|
||||||
|
3. Path normalization issues between scanned paths and added paths
|
||||||
|
|
||||||
|
### Bug 2: Duplicate File Removal Crash (FIXED)
|
||||||
|
|
||||||
|
**Symptom**: `failed to remove file: The system cannot find the file specified`
|
||||||
|
|
||||||
|
**Root cause**: Same file could be added to `removed_files` with both absolute and relative paths, causing double removal attempts.
|
||||||
|
|
||||||
|
**Fix applied**: In `fs_snapshot.rs`, gracefully handle "file not found" errors during removal since the file may have already been deleted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Context: The Syncback Work Done
|
## Context: The Syncback Work Done
|
||||||
|
|
||||||
This testing plan builds on significant syncback improvements:
|
This testing plan builds on significant syncback improvements:
|
||||||
|
|
|
||||||
|
|
@ -110,8 +110,31 @@ impl FsSnapshot {
|
||||||
for dir_path in &self.removed_dirs {
|
for dir_path in &self.removed_dirs {
|
||||||
lock.remove_dir_all(base_path.join(dir_path))?;
|
lock.remove_dir_all(base_path.join(dir_path))?;
|
||||||
}
|
}
|
||||||
|
// Only remove files that aren't already inside a directory we're removing.
|
||||||
|
// remove_dir_all already deleted those files recursively.
|
||||||
|
// Also handle the case where the same file might be listed twice (absolute vs relative path)
|
||||||
|
// by gracefully handling "file not found" errors.
|
||||||
for path in &self.removed_files {
|
for path in &self.removed_files {
|
||||||
lock.remove_file(base_path.join(path))?;
|
let is_inside_removed_dir = self
|
||||||
|
.removed_dirs
|
||||||
|
.iter()
|
||||||
|
.any(|dir| path.starts_with(dir));
|
||||||
|
if is_inside_removed_dir {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let full_path = base_path.join(path);
|
||||||
|
match lock.remove_file(&full_path) {
|
||||||
|
Ok(()) => (),
|
||||||
|
// File might have already been removed (e.g., by remove_dir_all, or listed twice
|
||||||
|
// with different path formats like absolute vs relative)
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||||
|
log::debug!(
|
||||||
|
"File already removed or doesn't exist: {}",
|
||||||
|
full_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
drop(lock);
|
drop(lock);
|
||||||
|
|
||||||
|
|
@ -120,10 +143,21 @@ impl FsSnapshot {
|
||||||
self.added_dirs.len(),
|
self.added_dirs.len(),
|
||||||
self.added_files.len()
|
self.added_files.len()
|
||||||
);
|
);
|
||||||
|
// Count how many files were skipped because they're inside removed directories
|
||||||
|
let files_inside_dirs = self
|
||||||
|
.removed_files
|
||||||
|
.iter()
|
||||||
|
.filter(|path| {
|
||||||
|
self.removed_dirs
|
||||||
|
.iter()
|
||||||
|
.any(|dir| path.starts_with(dir))
|
||||||
|
})
|
||||||
|
.count();
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"Removed {} directories and {} files from the file system",
|
"Removed {} directories and {} files from the file system ({} files skipped, inside removed dirs)",
|
||||||
self.removed_dirs.len(),
|
self.removed_dirs.len(),
|
||||||
self.removed_files.len()
|
self.removed_files.len() - files_inside_dirs,
|
||||||
|
files_inside_dirs
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,9 @@ pub fn syncback_loop_with_stats(
|
||||||
// not just what Rojo loaded into its tree. This ensures orphaned files
|
// not just what Rojo loaded into its tree. This ensures orphaned files
|
||||||
// (like duplicates Rojo couldn't load) get cleaned up properly.
|
// (like duplicates Rojo couldn't load) get cleaned up properly.
|
||||||
// Clean mode is essentially: "delete everything and rebuild from Studio"
|
// Clean mode is essentially: "delete everything and rebuild from Studio"
|
||||||
|
eprintln!("[DEBUG] incremental = {}", incremental);
|
||||||
let existing_paths: HashSet<PathBuf> = if !incremental {
|
let existing_paths: HashSet<PathBuf> = if !incremental {
|
||||||
|
eprintln!("[DEBUG] Clean mode: scanning filesystem for existing paths");
|
||||||
log::debug!("Clean mode: scanning filesystem for existing paths");
|
log::debug!("Clean mode: scanning filesystem for existing paths");
|
||||||
let mut paths = HashSet::new();
|
let mut paths = HashSet::new();
|
||||||
|
|
||||||
|
|
@ -246,9 +248,13 @@ pub fn syncback_loop_with_stats(
|
||||||
ignore_patterns: &Option<Vec<Glob>>,
|
ignore_patterns: &Option<Vec<Glob>>,
|
||||||
project_path: &Path,
|
project_path: &Path,
|
||||||
) {
|
) {
|
||||||
|
eprintln!("[DEBUG] scan_directory: {}", dir.display());
|
||||||
let entries = match std::fs::read_dir(dir) {
|
let entries = match std::fs::read_dir(dir) {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(_) => return,
|
Err(e) => {
|
||||||
|
eprintln!("[DEBUG] Failed to read directory {}: {}", dir.display(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
|
|
@ -256,16 +262,19 @@ pub fn syncback_loop_with_stats(
|
||||||
|
|
||||||
// Skip paths matching ignore patterns
|
// Skip paths matching ignore patterns
|
||||||
if !is_valid_path(ignore_patterns, project_path, &path) {
|
if !is_valid_path(ignore_patterns, project_path, &path) {
|
||||||
|
eprintln!("[DEBUG] Skipping {} (matches ignore pattern)", path.display());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip hidden files/directories (starting with .)
|
// Skip hidden files/directories (starting with .)
|
||||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||||
if name.starts_with('.') {
|
if name.starts_with('.') {
|
||||||
|
eprintln!("[DEBUG] Skipping {} (hidden)", path.display());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eprintln!("[DEBUG] Found: {} (is_dir: {})", path.display(), path.is_dir());
|
||||||
paths.insert(path.clone());
|
paths.insert(path.clone());
|
||||||
|
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
|
|
@ -274,18 +283,33 @@ pub fn syncback_loop_with_stats(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"[DEBUG] dirs_to_scan: {:?}",
|
||||||
|
dirs_to_scan.iter().map(|p| p.display().to_string()).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
for dir in &dirs_to_scan {
|
for dir in &dirs_to_scan {
|
||||||
if dir.is_dir() {
|
if dir.is_dir() {
|
||||||
|
eprintln!("[DEBUG] Scanning directory: {}", dir.display());
|
||||||
scan_directory(dir, &mut paths, &ignore_patterns, project_path);
|
scan_directory(dir, &mut paths, &ignore_patterns, project_path);
|
||||||
} else if dir.exists() {
|
} else if dir.exists() {
|
||||||
// It's a file, add it directly
|
// It's a file, add it directly
|
||||||
|
eprintln!("[DEBUG] Adding file directly: {}", dir.display());
|
||||||
if is_valid_path(&ignore_patterns, project_path, dir) {
|
if is_valid_path(&ignore_patterns, project_path, dir) {
|
||||||
paths.insert(dir.clone());
|
paths.insert(dir.clone());
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!("[DEBUG] Path does not exist: {}", dir.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::debug!("Scanned {} existing paths from filesystem", paths.len());
|
eprintln!(
|
||||||
|
"[DEBUG] Scanned {} existing paths from filesystem",
|
||||||
|
paths.len()
|
||||||
|
);
|
||||||
|
for p in &paths {
|
||||||
|
eprintln!("[DEBUG] - {}", p.display());
|
||||||
|
}
|
||||||
paths
|
paths
|
||||||
} else {
|
} else {
|
||||||
HashSet::new()
|
HashSet::new()
|
||||||
|
|
|
||||||
|
|
@ -996,11 +996,21 @@ impl ApiService {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
ExistingFileFormat::Standalone => {
|
ExistingFileFormat::Standalone => {
|
||||||
log::debug!(
|
// Standalone format can stay standalone only if there are no children.
|
||||||
"Syncback: Preserving existing standalone format for {}",
|
// If children were added in Studio, we must convert to directory format.
|
||||||
added.name
|
if has_children {
|
||||||
);
|
log::info!(
|
||||||
false
|
"Syncback: Converting {} from standalone to directory format (children added)",
|
||||||
|
added.name
|
||||||
|
);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
log::debug!(
|
||||||
|
"Syncback: Preserving existing standalone format for {}",
|
||||||
|
added.name
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ExistingFileFormat::None => {
|
ExistingFileFormat::None => {
|
||||||
// New instance - use has_children to decide
|
// New instance - use has_children to decide
|
||||||
|
|
@ -1010,8 +1020,10 @@ impl ApiService {
|
||||||
|
|
||||||
if use_directory {
|
if use_directory {
|
||||||
// Directory format: Name/init.luau
|
// Directory format: Name/init.luau
|
||||||
// Only remove standalone if this is a new instance transitioning to directory
|
// Remove standalone file if transitioning to directory (new instance or format conversion)
|
||||||
if existing_format == ExistingFileFormat::None {
|
if existing_format == ExistingFileFormat::None
|
||||||
|
|| existing_format == ExistingFileFormat::Standalone
|
||||||
|
{
|
||||||
let standalone_path = parent_dir.join(format!("{}.luau", added.name));
|
let standalone_path = parent_dir.join(format!("{}.luau", added.name));
|
||||||
if standalone_path.exists() {
|
if standalone_path.exists() {
|
||||||
if let Err(err) = fs::remove_file(&standalone_path) {
|
if let Err(err) = fs::remove_file(&standalone_path) {
|
||||||
|
|
@ -1090,18 +1102,30 @@ impl ApiService {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
ExistingFileFormat::Standalone => {
|
ExistingFileFormat::Standalone => {
|
||||||
log::debug!(
|
// Standalone format can stay standalone only if there are no children.
|
||||||
"Syncback: Preserving existing standalone format for Script {}",
|
// If children were added in Studio, we must convert to directory format.
|
||||||
added.name
|
if has_children {
|
||||||
);
|
log::info!(
|
||||||
false
|
"Syncback: Converting Script {} from standalone to directory format (children added)",
|
||||||
|
added.name
|
||||||
|
);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
log::debug!(
|
||||||
|
"Syncback: Preserving existing standalone format for Script {}",
|
||||||
|
added.name
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ExistingFileFormat::None => has_children,
|
ExistingFileFormat::None => has_children,
|
||||||
};
|
};
|
||||||
|
|
||||||
if use_directory {
|
if use_directory {
|
||||||
// Only clean up if this is a new instance
|
// Clean up standalone files if transitioning to directory (new instance or format conversion)
|
||||||
if existing_format == ExistingFileFormat::None {
|
if existing_format == ExistingFileFormat::None
|
||||||
|
|| existing_format == ExistingFileFormat::Standalone
|
||||||
|
{
|
||||||
for suffix in &["server", "client"] {
|
for suffix in &["server", "client"] {
|
||||||
let standalone_path =
|
let standalone_path =
|
||||||
parent_dir.join(format!("{}.{}.luau", added.name, suffix));
|
parent_dir.join(format!("{}.{}.luau", added.name, suffix));
|
||||||
|
|
@ -1159,18 +1183,30 @@ impl ApiService {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
ExistingFileFormat::Standalone => {
|
ExistingFileFormat::Standalone => {
|
||||||
log::debug!(
|
// Standalone format can stay standalone only if there are no children.
|
||||||
"Syncback: Preserving existing standalone format for LocalScript {}",
|
// If children were added in Studio, we must convert to directory format.
|
||||||
added.name
|
if has_children {
|
||||||
);
|
log::info!(
|
||||||
false
|
"Syncback: Converting LocalScript {} from standalone to directory format (children added)",
|
||||||
|
added.name
|
||||||
|
);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
log::debug!(
|
||||||
|
"Syncback: Preserving existing standalone format for LocalScript {}",
|
||||||
|
added.name
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ExistingFileFormat::None => has_children,
|
ExistingFileFormat::None => has_children,
|
||||||
};
|
};
|
||||||
|
|
||||||
if use_directory {
|
if use_directory {
|
||||||
// Only clean up if this is a new instance
|
// Clean up standalone files if transitioning to directory (new instance or format conversion)
|
||||||
if existing_format == ExistingFileFormat::None {
|
if existing_format == ExistingFileFormat::None
|
||||||
|
|| existing_format == ExistingFileFormat::Standalone
|
||||||
|
{
|
||||||
for suffix in &["local", "client"] {
|
for suffix in &["local", "client"] {
|
||||||
let standalone_path =
|
let standalone_path =
|
||||||
parent_dir.join(format!("{}.{}.luau", added.name, suffix));
|
parent_dir.join(format!("{}.{}.luau", added.name, suffix));
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod internable;
|
pub mod internable;
|
||||||
pub mod io_util;
|
pub mod io_util;
|
||||||
|
pub mod roundtrip_util;
|
||||||
pub mod serve_util;
|
pub mod serve_util;
|
||||||
pub mod syncback_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(())
|
||||||
|
}
|
||||||
522
tests/tests/clean_mode_stress.rs
Normal file
522
tests/tests/clean_mode_stress.rs
Normal file
|
|
@ -0,0 +1,522 @@
|
||||||
|
//! Stress tests proving clean mode produces consistent results.
|
||||||
|
//!
|
||||||
|
//! Core invariant: syncing back to a DIRTY directory with clean mode
|
||||||
|
//! should produce IDENTICAL results to syncing back to an EMPTY directory.
|
||||||
|
//!
|
||||||
|
//! These tests apply various "mutations" to dirty up the filesystem, then verify
|
||||||
|
//! clean mode fixes everything. This validates the clean mode implementation
|
||||||
|
//! handles edge cases correctly.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
use crate::rojo_test::io_util::BUILD_TESTS_PATH;
|
||||||
|
use crate::rojo_test::roundtrip_util::{
|
||||||
|
apply_mutation, assert_dirs_equal, copy_project_dir, run_rojo_build, run_rojo_syncback_clean,
|
||||||
|
Mutation,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Core test: dirty syncback should equal fresh syncback.
|
||||||
|
///
|
||||||
|
/// This is the fundamental invariant we're testing:
|
||||||
|
/// 1. Build rbxl from project
|
||||||
|
/// 2. Syncback to DIR_A (fresh)
|
||||||
|
/// 3. Apply mutations to DIR_A (dirty it up)
|
||||||
|
/// 4. Syncback again to DIR_A (clean mode should fix it)
|
||||||
|
/// 5. Syncback to DIR_B (completely fresh)
|
||||||
|
/// 6. Assert DIR_A == DIR_B
|
||||||
|
fn clean_equals_fresh(test_name: &str, mutations: &[Mutation]) {
|
||||||
|
let _ = env_logger::try_init();
|
||||||
|
|
||||||
|
let project_path = Path::new(BUILD_TESTS_PATH).join(test_name);
|
||||||
|
|
||||||
|
// 1. Build rbxm from original project (model format, not place)
|
||||||
|
let (_tmp_rbxm, rbxm_path) = run_rojo_build(&project_path, "test.rbxm");
|
||||||
|
|
||||||
|
// 2. Syncback to DIR_A (fresh start - copy full project structure)
|
||||||
|
let dir_a = tempdir().expect("Failed to create dir_a");
|
||||||
|
copy_project_dir(&project_path, dir_a.path());
|
||||||
|
assert!(
|
||||||
|
run_rojo_syncback_clean(dir_a.path(), &rbxm_path),
|
||||||
|
"Initial syncback to dir_a failed for {}",
|
||||||
|
test_name
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Apply mutations to DIR_A (dirty it up)
|
||||||
|
for mutation in mutations {
|
||||||
|
apply_mutation(dir_a.path(), mutation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Run clean syncback again on dirty DIR_A
|
||||||
|
assert!(
|
||||||
|
run_rojo_syncback_clean(dir_a.path(), &rbxm_path),
|
||||||
|
"Clean syncback on dirty dir_a failed for {}",
|
||||||
|
test_name
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Syncback to DIR_B (completely fresh - copy full project structure)
|
||||||
|
let dir_b = tempdir().expect("Failed to create dir_b");
|
||||||
|
copy_project_dir(&project_path, dir_b.path());
|
||||||
|
assert!(
|
||||||
|
run_rojo_syncback_clean(dir_b.path(), &rbxm_path),
|
||||||
|
"Fresh syncback to dir_b failed for {}",
|
||||||
|
test_name
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. CRITICAL ASSERTION: cleaned DIR_A == fresh DIR_B
|
||||||
|
assert_dirs_equal(dir_a.path(), dir_b.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ORPHAN FILE TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Clean mode should remove orphan .luau files that don't exist in the rbxl.
|
||||||
|
#[test]
|
||||||
|
fn clean_removes_orphan_luau_files() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"deep_nesting",
|
||||||
|
&[Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/orphan_script.luau",
|
||||||
|
content: "-- I shouldn't exist\nreturn nil",
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean mode should remove deeply nested orphan files.
|
||||||
|
#[test]
|
||||||
|
fn clean_removes_nested_orphan_files() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"deep_nesting",
|
||||||
|
&[Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/level-1/orphan_in_level1.luau",
|
||||||
|
content: "-- Nested orphan",
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean mode should remove orphan .txt files.
|
||||||
|
#[test]
|
||||||
|
fn clean_removes_orphan_txt_files() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"deep_nesting",
|
||||||
|
&[Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/orphan.txt",
|
||||||
|
content: "I shouldn't exist",
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean mode should remove orphan directories with content.
|
||||||
|
#[test]
|
||||||
|
fn clean_removes_orphan_directories() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"deep_nesting",
|
||||||
|
&[Mutation::AddOrphanDirectory {
|
||||||
|
relative_path: "src/fake_service",
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean mode should remove deeply nested orphan directories.
|
||||||
|
#[test]
|
||||||
|
fn clean_removes_deeply_nested_orphan_directories() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"deep_nesting",
|
||||||
|
&[
|
||||||
|
Mutation::AddOrphanDirectory {
|
||||||
|
relative_path: "src/level-1/level-2/orphan_folder",
|
||||||
|
},
|
||||||
|
Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/level-1/level-2/orphan_folder/nested/deep.luau",
|
||||||
|
content: "-- deeply nested orphan",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// FILE RESTORATION TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Clean mode should restore deleted files.
|
||||||
|
#[test]
|
||||||
|
fn clean_restores_deleted_files() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"module_init",
|
||||||
|
&[Mutation::DeleteFile {
|
||||||
|
relative_path: "folder/init.luau",
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean mode should restore deleted files in nested structures.
|
||||||
|
#[test]
|
||||||
|
fn clean_restores_deleted_nested_files() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"init_with_children",
|
||||||
|
&[
|
||||||
|
Mutation::DeleteFile {
|
||||||
|
relative_path: "src/init.luau",
|
||||||
|
},
|
||||||
|
Mutation::DeleteFile {
|
||||||
|
relative_path: "src/other.luau",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// FILE RENAME/NAME CORRECTION TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Clean mode should fix renamed files (creates orphan + restores correct name).
|
||||||
|
#[test]
|
||||||
|
fn clean_fixes_renamed_files() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"module_in_folder",
|
||||||
|
&[Mutation::RenameFile {
|
||||||
|
from: "folder/aModule.luau",
|
||||||
|
to: "folder/wrongName.luau",
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// EXTENSION CORRECTION TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Clean mode should fix wrong file extensions.
|
||||||
|
#[test]
|
||||||
|
fn clean_fixes_wrong_extensions() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"server_in_folder",
|
||||||
|
&[Mutation::ChangeExtension {
|
||||||
|
from: "folder/serverScript.server.luau",
|
||||||
|
to: "folder/serverScript.modulescript",
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean mode should fix .lua -> .luau extension issues.
|
||||||
|
#[test]
|
||||||
|
fn clean_fixes_lua_to_luau_extension() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"module_in_folder",
|
||||||
|
&[Mutation::ChangeExtension {
|
||||||
|
from: "folder/aModule.luau",
|
||||||
|
to: "folder/aModule.lua",
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// FORMAT CONVERSION TESTS (DIR <-> FILE)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Clean mode should restore directory format from standalone file.
|
||||||
|
#[test]
|
||||||
|
fn clean_restores_dir_from_file() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"init_with_children",
|
||||||
|
&[Mutation::ConvertDirToFile {
|
||||||
|
dir: "src",
|
||||||
|
file_content: "-- was a directory\nreturn {}",
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean mode should restore standalone file from directory format.
|
||||||
|
#[test]
|
||||||
|
fn clean_restores_file_from_dir() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"module_in_folder",
|
||||||
|
&[Mutation::ConvertFileToDir {
|
||||||
|
file: "folder/aModule.luau",
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// META FILE TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Clean mode should fix corrupted meta files.
|
||||||
|
#[test]
|
||||||
|
fn clean_fixes_corrupt_meta_files() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"init_meta_properties",
|
||||||
|
&[Mutation::CorruptMetaFile {
|
||||||
|
relative_path: "Lighting/init.meta.json5",
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean mode should remove orphan meta files.
|
||||||
|
#[test]
|
||||||
|
fn clean_removes_orphan_meta_files() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"deep_nesting",
|
||||||
|
&[Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/orphan.meta.json5",
|
||||||
|
content: r#"{ "className": "Folder" }"#,
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CONTENT MODIFICATION TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Clean mode should restore modified file content.
|
||||||
|
#[test]
|
||||||
|
fn clean_restores_modified_content() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"module_init",
|
||||||
|
&[Mutation::ModifyFileContent {
|
||||||
|
relative_path: "folder/init.luau",
|
||||||
|
new_content: "-- totally wrong content\nreturn 'hacked'",
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean mode should restore modified content in multiple files.
|
||||||
|
#[test]
|
||||||
|
fn clean_restores_multiple_modified_files() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"init_with_children",
|
||||||
|
&[
|
||||||
|
Mutation::ModifyFileContent {
|
||||||
|
relative_path: "src/init.luau",
|
||||||
|
new_content: "-- wrong init",
|
||||||
|
},
|
||||||
|
Mutation::ModifyFileContent {
|
||||||
|
relative_path: "src/other.luau",
|
||||||
|
new_content: "-- wrong other",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PROJECT FILE TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Clean mode should remove spurious nested project files.
|
||||||
|
#[test]
|
||||||
|
fn clean_removes_spurious_project_files() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"deep_nesting",
|
||||||
|
&[Mutation::AddNestedProjectFile {
|
||||||
|
relative_path: "src/fake.project.json5",
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean mode should remove multiple spurious project files.
|
||||||
|
#[test]
|
||||||
|
fn clean_removes_multiple_spurious_project_files() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"deep_nesting",
|
||||||
|
&[
|
||||||
|
Mutation::AddNestedProjectFile {
|
||||||
|
relative_path: "src/fake1.project.json5",
|
||||||
|
},
|
||||||
|
Mutation::AddNestedProjectFile {
|
||||||
|
relative_path: "src/level-1/fake2.project.json5",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// DUPLICATE FILE TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Clean mode should remove duplicate files with different extensions.
|
||||||
|
#[test]
|
||||||
|
fn clean_removes_duplicate_extensions() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"module_in_folder",
|
||||||
|
&[Mutation::DuplicateWithDifferentExtension {
|
||||||
|
original: "folder/aModule.luau",
|
||||||
|
duplicate_ext: "lua",
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean mode should handle multiple duplicates.
|
||||||
|
#[test]
|
||||||
|
fn clean_removes_multiple_duplicates() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"module_in_folder",
|
||||||
|
&[
|
||||||
|
Mutation::DuplicateWithDifferentExtension {
|
||||||
|
original: "folder/aModule.luau",
|
||||||
|
duplicate_ext: "lua",
|
||||||
|
},
|
||||||
|
Mutation::DuplicateWithDifferentExtension {
|
||||||
|
original: "folder/aModule.luau",
|
||||||
|
duplicate_ext: "modulescript",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// COMBINED / STRESS TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Clean mode handles multiple different mutations at once.
|
||||||
|
#[test]
|
||||||
|
fn clean_handles_multiple_mutations() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"deep_nesting",
|
||||||
|
&[
|
||||||
|
Mutation::AddOrphanFile {
|
||||||
|
relative_path: "orphan1.luau",
|
||||||
|
content: "x",
|
||||||
|
},
|
||||||
|
Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/orphan2.txt",
|
||||||
|
content: "y",
|
||||||
|
},
|
||||||
|
Mutation::AddOrphanDirectory {
|
||||||
|
relative_path: "fake_dir",
|
||||||
|
},
|
||||||
|
Mutation::AddNestedProjectFile {
|
||||||
|
relative_path: "src/bad.project.json5",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean mode handles extreme chaos with many mutations.
|
||||||
|
#[test]
|
||||||
|
fn clean_handles_extreme_chaos() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"deep_nesting",
|
||||||
|
&[
|
||||||
|
// Orphan files everywhere
|
||||||
|
Mutation::AddOrphanFile {
|
||||||
|
relative_path: "orphan_root.luau",
|
||||||
|
content: "-- root level orphan",
|
||||||
|
},
|
||||||
|
Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/orphan_src.luau",
|
||||||
|
content: "-- src level orphan",
|
||||||
|
},
|
||||||
|
Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/level-1/orphan_l1.luau",
|
||||||
|
content: "-- l1 orphan",
|
||||||
|
},
|
||||||
|
Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/level-1/level-2/orphan_l2.luau",
|
||||||
|
content: "-- l2 orphan",
|
||||||
|
},
|
||||||
|
// Orphan directories
|
||||||
|
Mutation::AddOrphanDirectory {
|
||||||
|
relative_path: "fake_service",
|
||||||
|
},
|
||||||
|
Mutation::AddOrphanDirectory {
|
||||||
|
relative_path: "src/fake_folder",
|
||||||
|
},
|
||||||
|
// Orphan meta files
|
||||||
|
Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/fake.meta.json5",
|
||||||
|
content: "{}",
|
||||||
|
},
|
||||||
|
// Spurious project files
|
||||||
|
Mutation::AddNestedProjectFile {
|
||||||
|
relative_path: "src/rogue.project.json5",
|
||||||
|
},
|
||||||
|
// Various file types
|
||||||
|
Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/orphan.txt",
|
||||||
|
content: "text orphan",
|
||||||
|
},
|
||||||
|
Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/orphan.json5",
|
||||||
|
content: "{}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean mode handles mutations across different project types.
|
||||||
|
#[test]
|
||||||
|
fn clean_handles_mutations_on_init_project() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"module_init",
|
||||||
|
&[
|
||||||
|
Mutation::AddOrphanFile {
|
||||||
|
relative_path: "folder/orphan.luau",
|
||||||
|
content: "-- orphan",
|
||||||
|
},
|
||||||
|
Mutation::ModifyFileContent {
|
||||||
|
relative_path: "folder/init.luau",
|
||||||
|
new_content: "-- modified",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean mode handles mutations that affect nested content.
|
||||||
|
#[test]
|
||||||
|
fn clean_handles_mutations_on_nested_project() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"init_with_children",
|
||||||
|
&[
|
||||||
|
Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/extra_child.luau",
|
||||||
|
content: "-- extra",
|
||||||
|
},
|
||||||
|
Mutation::DeleteFile {
|
||||||
|
relative_path: "src/other.luau",
|
||||||
|
},
|
||||||
|
Mutation::ModifyFileContent {
|
||||||
|
relative_path: "src/init.luau",
|
||||||
|
new_content: "-- wrong",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// EDGE CASE TESTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Clean mode handles empty orphan directories.
|
||||||
|
#[test]
|
||||||
|
fn clean_handles_empty_orphan_directory() {
|
||||||
|
// The AddOrphanDirectory mutation adds a child file, but let's test
|
||||||
|
// with just adding an empty-ish directory structure
|
||||||
|
clean_equals_fresh(
|
||||||
|
"deep_nesting",
|
||||||
|
&[
|
||||||
|
Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/empty_dir/.gitkeep",
|
||||||
|
content: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean mode handles orphan files with special characters in names.
|
||||||
|
#[test]
|
||||||
|
fn clean_handles_special_character_names() {
|
||||||
|
clean_equals_fresh(
|
||||||
|
"deep_nesting",
|
||||||
|
&[
|
||||||
|
Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/file-with-dashes.luau",
|
||||||
|
content: "-- dashes",
|
||||||
|
},
|
||||||
|
Mutation::AddOrphanFile {
|
||||||
|
relative_path: "src/file_with_underscores.luau",
|
||||||
|
content: "-- underscores",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
mod build;
|
mod build;
|
||||||
mod clean_mode;
|
mod clean_mode;
|
||||||
|
mod clean_mode_stress;
|
||||||
mod serve;
|
mod serve;
|
||||||
mod syncback;
|
mod syncback;
|
||||||
mod syncback_format_transitions;
|
mod syncback_format_transitions;
|
||||||
|
mod syncback_roundtrip;
|
||||||
|
|
|
||||||
273
tests/tests/syncback_roundtrip.rs
Normal file
273
tests/tests/syncback_roundtrip.rs
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
//! Build -> Syncback -> Rebuild roundtrip tests.
|
||||||
|
//!
|
||||||
|
//! These tests verify that:
|
||||||
|
//! 1. Building a project to rbxl
|
||||||
|
//! 2. Syncing back to a fresh directory
|
||||||
|
//! 3. Rebuilding from the syncback result
|
||||||
|
//!
|
||||||
|
//! Produces semantically identical output. This validates that the syncback
|
||||||
|
//! system correctly reconstructs project structure from binary place files.
|
||||||
|
|
||||||
|
use std::{fs, path::Path};
|
||||||
|
|
||||||
|
use rbx_dom_weak::{ustr, WeakDom};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
use crate::rojo_test::io_util::BUILD_TESTS_PATH;
|
||||||
|
use crate::rojo_test::roundtrip_util::{copy_project_dir, run_rojo_build, run_rojo_syncback_clean};
|
||||||
|
|
||||||
|
/// Generate roundtrip tests for build-test projects.
|
||||||
|
///
|
||||||
|
/// Each test:
|
||||||
|
/// 1. Builds the project to an rbxl file
|
||||||
|
/// 2. Creates a fresh directory with just the project file
|
||||||
|
/// 3. Syncs back from the rbxl to the fresh directory
|
||||||
|
/// 4. Rebuilds from the syncback result
|
||||||
|
/// 5. Compares the two rbxl files for semantic equality
|
||||||
|
macro_rules! roundtrip_tests {
|
||||||
|
($($test_name:ident),* $(,)?) => {$(
|
||||||
|
#[test]
|
||||||
|
fn $test_name() {
|
||||||
|
run_roundtrip_test(stringify!($test_name));
|
||||||
|
}
|
||||||
|
)*};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_roundtrip_test(build_test_name: &str) {
|
||||||
|
let _ = env_logger::try_init();
|
||||||
|
|
||||||
|
let project_path = Path::new(BUILD_TESTS_PATH).join(build_test_name);
|
||||||
|
|
||||||
|
// 1. Build original project -> rbxm (model format, not place)
|
||||||
|
let (_tmp1, original_rbxm) = run_rojo_build(&project_path, "original.rbxm");
|
||||||
|
|
||||||
|
// 2. Create fresh syncback target with full project structure
|
||||||
|
let syncback_dir = tempdir().expect("Failed to create temp dir");
|
||||||
|
copy_project_dir(&project_path, syncback_dir.path());
|
||||||
|
|
||||||
|
// 3. Syncback from rbxm to directory
|
||||||
|
assert!(
|
||||||
|
run_rojo_syncback_clean(syncback_dir.path(), &original_rbxm),
|
||||||
|
"Syncback should succeed for {}",
|
||||||
|
build_test_name
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Build from syncback result -> rbxm
|
||||||
|
let (_tmp2, roundtrip_rbxm) = run_rojo_build(syncback_dir.path(), "roundtrip.rbxm");
|
||||||
|
|
||||||
|
// 5. Compare both rbxm files (semantic comparison)
|
||||||
|
assert_rbx_equal(&original_rbxm, &roundtrip_rbxm, build_test_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare two rbxl files for semantic equality.
|
||||||
|
///
|
||||||
|
/// This compares the instance trees, checking that all instances have the same
|
||||||
|
/// names, class names, and properties (excluding transient properties like Ref).
|
||||||
|
fn assert_rbx_equal(file_a: &Path, file_b: &Path, test_name: &str) {
|
||||||
|
let data_a = fs::read(file_a).expect("Failed to read original rbxl");
|
||||||
|
let data_b = fs::read(file_b).expect("Failed to read roundtrip rbxl");
|
||||||
|
|
||||||
|
let dom_a = rbx_binary::from_reader(data_a.as_slice()).expect("Failed to parse original rbxl");
|
||||||
|
let dom_b =
|
||||||
|
rbx_binary::from_reader(data_b.as_slice()).expect("Failed to parse roundtrip rbxl");
|
||||||
|
|
||||||
|
// Compare the root's children (DataModel children)
|
||||||
|
compare_children(&dom_a, dom_a.root_ref(), &dom_b, dom_b.root_ref(), test_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively compare children of two instances.
|
||||||
|
fn compare_children(
|
||||||
|
dom_a: &WeakDom,
|
||||||
|
ref_a: rbx_dom_weak::types::Ref,
|
||||||
|
dom_b: &WeakDom,
|
||||||
|
ref_b: rbx_dom_weak::types::Ref,
|
||||||
|
test_name: &str,
|
||||||
|
) {
|
||||||
|
let inst_a = dom_a.get_by_ref(ref_a).expect("Instance A should exist");
|
||||||
|
let inst_b = dom_b.get_by_ref(ref_b).expect("Instance B should exist");
|
||||||
|
|
||||||
|
let children_a = inst_a.children();
|
||||||
|
let children_b = inst_b.children();
|
||||||
|
|
||||||
|
// Build maps of children by name for comparison
|
||||||
|
let mut map_a: std::collections::HashMap<&str, &rbx_dom_weak::Instance> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
let mut map_b: std::collections::HashMap<&str, &rbx_dom_weak::Instance> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
|
for &child_ref in children_a {
|
||||||
|
if let Some(child) = dom_a.get_by_ref(child_ref) {
|
||||||
|
map_a.insert(&child.name, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for &child_ref in children_b {
|
||||||
|
if let Some(child) = dom_b.get_by_ref(child_ref) {
|
||||||
|
map_b.insert(&child.name, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that both have the same children
|
||||||
|
let names_a: std::collections::HashSet<&str> = map_a.keys().copied().collect();
|
||||||
|
let names_b: std::collections::HashSet<&str> = map_b.keys().copied().collect();
|
||||||
|
|
||||||
|
let only_in_a: Vec<_> = names_a.difference(&names_b).collect();
|
||||||
|
let only_in_b: Vec<_> = names_b.difference(&names_a).collect();
|
||||||
|
|
||||||
|
if !only_in_a.is_empty() || !only_in_b.is_empty() {
|
||||||
|
panic!(
|
||||||
|
"[{}] Children differ under '{}':\n Only in original: {:?}\n Only in roundtrip: {:?}",
|
||||||
|
test_name, inst_a.name, only_in_a, only_in_b
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare each child
|
||||||
|
for name in names_a {
|
||||||
|
let child_a = map_a[name];
|
||||||
|
let child_b = map_b[name];
|
||||||
|
|
||||||
|
// Check class name matches
|
||||||
|
if child_a.class != child_b.class {
|
||||||
|
panic!(
|
||||||
|
"[{}] Class mismatch for '{}': original={}, roundtrip={}",
|
||||||
|
test_name, name, child_a.class, child_b.class
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare important properties (Source for scripts, Value for value types)
|
||||||
|
compare_properties(child_a, child_b, test_name);
|
||||||
|
|
||||||
|
// Recurse into children
|
||||||
|
let child_ref_a = dom_a
|
||||||
|
.get_by_ref(ref_a)
|
||||||
|
.unwrap()
|
||||||
|
.children()
|
||||||
|
.iter()
|
||||||
|
.find(|&&r| dom_a.get_by_ref(r).map(|i| i.name.as_str()) == Some(name))
|
||||||
|
.copied()
|
||||||
|
.unwrap();
|
||||||
|
let child_ref_b = dom_b
|
||||||
|
.get_by_ref(ref_b)
|
||||||
|
.unwrap()
|
||||||
|
.children()
|
||||||
|
.iter()
|
||||||
|
.find(|&&r| dom_b.get_by_ref(r).map(|i| i.name.as_str()) == Some(name))
|
||||||
|
.copied()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
compare_children(dom_a, child_ref_a, dom_b, child_ref_b, test_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare properties of two instances.
|
||||||
|
///
|
||||||
|
/// Focuses on properties that matter for roundtrip correctness:
|
||||||
|
/// - Source (for scripts)
|
||||||
|
/// - Value (for value types)
|
||||||
|
/// - Other serializable properties
|
||||||
|
fn compare_properties(
|
||||||
|
inst_a: &rbx_dom_weak::Instance,
|
||||||
|
inst_b: &rbx_dom_weak::Instance,
|
||||||
|
test_name: &str,
|
||||||
|
) {
|
||||||
|
// Check Source property for scripts
|
||||||
|
if let (Some(source_a), Some(source_b)) = (
|
||||||
|
inst_a.properties.get(&ustr("Source")),
|
||||||
|
inst_b.properties.get(&ustr("Source")),
|
||||||
|
) {
|
||||||
|
if source_a != source_b {
|
||||||
|
panic!(
|
||||||
|
"[{}] Source property differs for '{}' ({}):\nOriginal: {:?}\nRoundtrip: {:?}",
|
||||||
|
test_name, inst_a.name, inst_a.class, source_a, source_b
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Value property for value types
|
||||||
|
if let (Some(value_a), Some(value_b)) = (
|
||||||
|
inst_a.properties.get(&ustr("Value")),
|
||||||
|
inst_b.properties.get(&ustr("Value")),
|
||||||
|
) {
|
||||||
|
if value_a != value_b {
|
||||||
|
panic!(
|
||||||
|
"[{}] Value property differs for '{}' ({}):\nOriginal: {:?}\nRoundtrip: {:?}",
|
||||||
|
test_name, inst_a.name, inst_a.class, value_a, value_b
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Attributes if present
|
||||||
|
if let (Some(attrs_a), Some(attrs_b)) = (
|
||||||
|
inst_a.properties.get(&ustr("Attributes")),
|
||||||
|
inst_b.properties.get(&ustr("Attributes")),
|
||||||
|
) {
|
||||||
|
if attrs_a != attrs_b {
|
||||||
|
panic!(
|
||||||
|
"[{}] Attributes differ for '{}' ({}):\nOriginal: {:?}\nRoundtrip: {:?}",
|
||||||
|
test_name, inst_a.name, inst_a.class, attrs_a, attrs_b
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ROUNDTRIP TESTS
|
||||||
|
// =============================================================================
|
||||||
|
//
|
||||||
|
// These are build-test projects that should roundtrip cleanly through
|
||||||
|
// build -> syncback -> rebuild.
|
||||||
|
//
|
||||||
|
// Some projects are excluded because they:
|
||||||
|
// - Use sync rules with custom extensions (sync_rule_* projects)
|
||||||
|
// - Have CSV files that may not roundtrip identically
|
||||||
|
// - Have special project configurations that affect syncback
|
||||||
|
|
||||||
|
roundtrip_tests! {
|
||||||
|
// Basic script types
|
||||||
|
client_in_folder,
|
||||||
|
client_init,
|
||||||
|
module_in_folder,
|
||||||
|
module_init,
|
||||||
|
server_in_folder,
|
||||||
|
server_init,
|
||||||
|
|
||||||
|
// Nested structures
|
||||||
|
deep_nesting,
|
||||||
|
init_with_children,
|
||||||
|
|
||||||
|
// Meta files and properties
|
||||||
|
init_meta_class_name,
|
||||||
|
init_meta_properties,
|
||||||
|
script_meta_disabled,
|
||||||
|
|
||||||
|
// JSON models
|
||||||
|
json_model_in_folder,
|
||||||
|
json_model_legacy_name,
|
||||||
|
|
||||||
|
// Text files
|
||||||
|
txt,
|
||||||
|
txt_in_folder,
|
||||||
|
|
||||||
|
// Binary models (rbxm/rbxmx)
|
||||||
|
rbxm_in_folder,
|
||||||
|
rbxmx_in_folder,
|
||||||
|
rbxmx_ref,
|
||||||
|
|
||||||
|
// Composed projects
|
||||||
|
project_composed_default,
|
||||||
|
project_composed_file,
|
||||||
|
project_root_name,
|
||||||
|
|
||||||
|
// Special cases
|
||||||
|
attributes,
|
||||||
|
gitkeep,
|
||||||
|
optional,
|
||||||
|
weldconstraint,
|
||||||
|
|
||||||
|
// Service inference
|
||||||
|
infer_service_name,
|
||||||
|
infer_starter_player,
|
||||||
|
|
||||||
|
// Issue regression tests
|
||||||
|
issue_546,
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue