Merge pull request #2947 from spacedriveapp/windows-build

Windows NTFS support
This commit is contained in:
Jamie Pine
2026-01-03 14:02:51 -08:00
committed by GitHub
43 changed files with 3110 additions and 2766 deletions

View File

@@ -162,86 +162,6 @@ jobs:
# if: steps.filter.outcome != 'success' || steps.filter.outputs.changes == 'true'
# run: cargo test --workspace --all-features --locked --target ${{ matrix.settings.target }}
build:
name: Build CLI (${{ matrix.settings.platform }})
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
strategy:
fail-fast: false
matrix:
settings:
- host: macos-14
target: aarch64-apple-darwin
platform: macos-aarch64
- host: ubuntu-22.04
target: x86_64-unknown-linux-gnu
platform: linux-x86_64
- host: windows-latest
target: x86_64-pc-windows-msvc
platform: windows-x86_64
runs-on: ${{ matrix.settings.host }}
permissions:
contents: read
timeout-minutes: 45
steps:
- name: Maximize build space
if: ${{ runner.os == 'Linux' }}
uses: easimon/maximize-build-space@master
with:
swap-size-mb: 3072
root-reserve-mb: 6144
remove-dotnet: "true"
remove-codeql: "true"
remove-haskell: "true"
remove-docker-images: "true"
- name: Symlink target to C:\
if: ${{ runner.os == 'Windows' }}
shell: powershell
run: |
New-Item -ItemType Directory -Force -Path C:\spacedrive_target
New-Item -Path target -ItemType Junction -Value C:\spacedrive_target
- name: Checkout repository
uses: actions/checkout@v4
# OPTIONAL: Re-enable when submodule repos are public
# with:
# submodules: recursive
- name: Setup System and Rust
uses: ./.github/actions/setup-system
with:
token: ${{ secrets.GITHUB_TOKEN }}
target: ${{ matrix.settings.target }}
- name: Setup native dependencies
run: cargo run -p xtask -- setup
- name: Build CLI binaries
run: cargo build --release --bin sd-cli --bin sd-daemon --features heif,ffmpeg --target ${{ matrix.settings.target }}
- name: Prepare binaries (Unix)
if: runner.os != 'Windows'
run: |
mkdir -p dist
cp target/${{ matrix.settings.target }}/release/sd-cli dist/sd-${{ matrix.settings.platform }}
cp target/${{ matrix.settings.target }}/release/sd-daemon dist/sd-daemon-${{ matrix.settings.platform }}
chmod +x dist/*
- name: Prepare binaries (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path dist
Copy-Item target/${{ matrix.settings.target }}/release/sd-cli.exe dist/sd-${{ matrix.settings.platform }}.exe
Copy-Item target/${{ matrix.settings.target }}/release/sd-daemon.exe dist/sd-daemon-${{ matrix.settings.platform }}.exe
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: cli-${{ matrix.settings.platform }}
path: dist/*
retention-days: 7
typescript:
name: TypeScript
runs-on: ubuntu-22.04

View File

@@ -8,53 +8,83 @@ on:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
RUSTUP_MAX_RETRIES: 10
jobs:
test:
runs-on: self-hosted
strategy:
fail-fast: false
matrix:
settings:
- host: [self-hosted, macOS, ARM64]
target: aarch64-apple-darwin
os: macos
- host: ubuntu-22.04
target: x86_64-unknown-linux-gnu
os: linux
- host: [self-hosted, Windows, X64]
target: x86_64-pc-windows-msvc
os: windows
name: Test Core - ${{ matrix.settings.os }}
runs-on: ${{ matrix.settings.host }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: actions/checkout@v4
- name: Maximize build space
if: ${{ matrix.settings.os == 'linux' }}
uses: easimon/maximize-build-space@master
with:
swap-size-mb: 3072
root-reserve-mb: 6144
remove-dotnet: "true"
remove-codeql: "true"
remove-haskell: "true"
remove-docker-images: "true"
- name: Install Rust toolchain
- name: Symlink target to C:\
if: ${{ matrix.settings.os == 'windows' }}
shell: cmd
run: |
if exist target rmdir /S /Q target
if not exist C:\spacedrive_target mkdir C:\spacedrive_target
mklink /J target C:\spacedrive_target
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.81"
targets: ${{ matrix.settings.target }}
- name: Setup System and Rust
uses: ./.github/actions/setup-system
with:
token: ${{ secrets.GITHUB_TOKEN }}
target: ${{ matrix.settings.target }}
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
key: ${{ matrix.settings.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
key: ${{ matrix.settings.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
key: ${{ matrix.settings.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- name: Build core
run: cargo build -p sd-core --verbose
- name: Setup native dependencies
run: cargo run -p xtask -- setup
- name: Run all tests
run: |
cargo test -p sd-core --lib -- --test-threads=1 --nocapture
cargo test -p sd-core --test indexing_test -- --test-threads=1 --nocapture
cargo test -p sd-core --test indexing_rules_test -- --test-threads=1 --nocapture
cargo test -p sd-core --test indexing_responder_reindex_test -- --test-threads=1 --nocapture
cargo test -p sd-core --test sync_backfill_test -- --test-threads=1 --nocapture
cargo test -p sd-core --test sync_backfill_race_test -- --test-threads=1 --nocapture
cargo test -p sd-core --test sync_event_log_test -- --test-threads=1 --nocapture
cargo test -p sd-core --test sync_metrics_test -- --test-threads=1 --nocapture
cargo test -p sd-core --test sync_realtime_test -- --test-threads=1 --nocapture
cargo test -p sd-core --test sync_setup_test -- --test-threads=1 --nocapture
cargo test -p sd-core --test file_sync_simple_test -- --test-threads=1 --nocapture
cargo test -p sd-core --test file_sync_test -- --test-threads=1 --nocapture
cargo test -p sd-core --test database_migration_test -- --test-threads=1 --nocapture
run: cargo run -p xtask -- test-core --verbose

View File

@@ -0,0 +1,332 @@
---
id: CORE-015
title: "Windows File ID Tracking for Stable File Identity"
status: Done
assignee: jamiepine
priority: High
tags: [core, windows, indexing, platform]
last_updated: 2025-12-29
---
## Description
Implement Windows File ID support in the indexer to enable stable file identification across renames on Windows. This brings Windows to feature parity with Unix/Linux/macOS for change detection and UUID persistence.
**Problem:**
Currently, Windows files don't have stable identifiers across renames because `get_inode()` returns `None`. This means:
- Renamed files are treated as delete + create
- UUIDs are not preserved across renames
- Tags, metadata, and relationships are lost
- Files must be re-indexed and re-hashed unnecessarily
**Solution:**
Use Windows NTFS File IDs (64-bit file index) as the equivalent of Unix inodes for stable file identification.
## Background
### Platform Differences
**Unix/Linux/macOS:**
- Files identified by inode number (stable across renames)
- `std::os::unix::fs::MetadataExt::ino()` provides stable API
- Change detection: inode match + path change = file moved
**Windows (current):**
- Returns `None` for inode → falls back to path-only matching
- Renamed files treated as new files
- UUID and metadata lost on rename
**Windows (with File IDs):**
- NTFS provides 64-bit File ID (similar to inode)
- Stable across renames within a volume
- Enables proper move/rename detection
### What Are Windows File IDs?
Windows NTFS File IDs are unique identifiers exposed via the Win32 API:
```c
typedef struct _BY_HANDLE_FILE_INFORMATION {
DWORD nFileIndexHigh; // Upper 32 bits
DWORD nFileIndexLow; // Lower 32 bits
// ... other fields
} BY_HANDLE_FILE_INFORMATION;
// Combined: 64-bit unique identifier
uint64_t file_id = ((uint64_t)nFileIndexHigh << 32) | nFileIndexLow;
```
**Properties:**
- ✅ Unique per file within a volume
- ✅ Stable across file renames
- ✅ Stable across reboots
- ⚠️ Changes when file copied to different volume (expected)
- ⚠️ Not available on FAT32/exFAT
- ⚠️ Theoretically can change during defragmentation (rare)
### Why Currently Disabled
```rust
// core/src/ops/indexing/database_storage.rs:145-152
#[cfg(windows)]
pub fn get_inode(_metadata: &std::fs::Metadata) -> Option<u64> {
// Windows file indices exist but are unstable across reboots and
// volume operations, making them unsuitable for change detection.
None
}
```
**Reasons:**
1. Rust's `std::os::windows::fs::MetadataExt::file_index()` is unstable (requires nightly)
2. Conservative assumption about stability (outdated - File IDs are actually stable)
3. No Windows-specific dependencies currently in codebase
**Reality:**
Modern NTFS File IDs are stable and reliable. The comment is outdated and overly conservative.
## User Impact
### Without File IDs (current behavior)
```
User action: Rename "Project.mp4" → "Final Project.mp4"
Spacedrive sees:
- DELETE: Project.mp4 (UUID: abc-123)
- CREATE: Final Project.mp4 (UUID: def-456) ← New UUID!
Result:
- All tags lost
- All metadata lost
- Relationships broken
- File re-indexed from scratch
- Content re-hashed (expensive for large files)
```
### With File IDs (desired behavior)
```
User action: Rename "Project.mp4" → "Final Project.mp4"
Spacedrive sees:
- MOVE: File ID 0x123ABC from "Project.mp4" to "Final Project.mp4"
- UUID: abc-123 (preserved)
Result:
- Tags preserved
- Metadata intact
- Relationships maintained
- No re-indexing needed
- No re-hashing needed
```
## Acceptance Criteria
### Core Implementation
- [ ] Add `windows-sys` dependency for File ID access
- [ ] Implement `get_inode()` for Windows using `GetFileInformationByHandle`
- [ ] Extract 64-bit File ID from `nFileIndexHigh` and `nFileIndexLow`
- [ ] Return `None` gracefully for non-NTFS filesystems (FAT32, exFAT)
- [ ] Add tracing/logging for File ID extraction success/failure
### Change Detection
- [ ] File renames detected as moves (not delete + create)
- [ ] UUIDs preserved across renames within a volume
- [ ] Tags and metadata preserved across renames
- [ ] Cross-volume copies create new UUIDs (expected behavior)
### Error Handling
- [ ] Handle FAT32/exFAT gracefully (return `None`, fall back to path matching)
- [ ] Handle permission errors (return `None`, log debug message)
- [ ] Handle invalid handles (return `None`, log debug message)
- [ ] No panics or crashes on unsupported filesystems
### Documentation
- [ ] Update code comments to reflect actual File ID stability
- [ ] Document NTFS requirement for File ID support
- [ ] Document known limitations (cross-volume, FAT32, defrag edge case)
- [ ] Add platform comparison table to developer docs
## Implementation Plan
### Option 1: Use `windows-sys` Crate (Recommended)
**Add dependency:**
```toml
# core/Cargo.toml
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.52", features = ["Win32_Storage_FileSystem"] }
```
**Implement File ID extraction:**
```rust
// core/src/ops/indexing/database_storage.rs
#[cfg(windows)]
pub fn get_inode(path: &Path) -> Option<u64> {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION
};
// Open file to get handle
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) => {
tracing::debug!("Failed to open file for File ID extraction: {}", e);
return None;
}
};
let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
unsafe {
if GetFileInformationByHandle(file.as_raw_handle() as isize, &mut info) != 0 {
// Combine high and low 32-bit values into 64-bit File ID
let file_id = ((info.nFileIndexHigh as u64) << 32) | (info.nFileIndexLow as u64);
tracing::trace!(
"Extracted File ID: 0x{:016X} for {:?}",
file_id,
path.file_name().unwrap_or_default()
);
Some(file_id)
} else {
// GetFileInformationByHandle failed
// Common reasons: FAT32/exFAT filesystem, permission denied
tracing::debug!(
"GetFileInformationByHandle failed for {:?} (likely FAT32 or permission issue)",
path.file_name().unwrap_or_default()
);
None
}
}
}
```
**Why `windows-sys`:**
- Official Microsoft-maintained bindings
- Minimal overhead (only includes what you use)
- Safe Rust wrappers where possible
- Future-proof and actively maintained
### Option 2: Wait for Rust Stabilization (Not Recommended)
**Track:** https://github.com/rust-lang/rust/issues/63010
```rust
// Would be ideal, but unstable since 2019
#[cfg(windows)]
pub fn get_inode(metadata: &std::fs::Metadata) -> Option<u64> {
use std::os::windows::fs::MetadataExt;
metadata.file_index() // ← requires #![feature(windows_by_handle)]
}
```
**Why not recommended:**
- Unstable since 2019, no timeline for stabilization
- Requires nightly Rust
- Blocks production use
- No guarantee it will ever stabilize
## Implementation Files
**Files to modify:**
1. `core/Cargo.toml` - Add `windows-sys` dependency
2. `core/src/ops/indexing/database_storage.rs` - Implement `get_inode()` for Windows
3. `core/src/volume/backend/local.rs` - Implement `get_inode()` for Windows (same code)
**Total changes:** ~30 lines of code across 3 files
## Known Limitations
### 1. Cross-Volume Operations
File IDs are volume-specific. When files are **copied** between volumes:
- Source file keeps original File ID
- Destination file gets new File ID (correct behavior)
- Spacedrive creates new UUID for destination (expected)
### 2. Non-NTFS Filesystems
FAT32 and exFAT don't support File IDs:
- `GetFileInformationByHandle` returns all zeros or fails
- Implementation returns `None`
- Falls back to path-only matching (same as current behavior)
### 3. Defragmentation Edge Case
File IDs can theoretically change during defragmentation:
- Extremely rare with modern NTFS
- If it happens, file treated as delete + create
- Acceptable trade-off for 99.9% reliability
### 4. Hard Links
NTFS supports hard links for files (not directories):
- Multiple paths → same File ID (correct behavior)
- Spacedrive treats as same file with multiple locations (desired)
## Success Metrics
- [ ] File renames preserve UUIDs on Windows NTFS volumes
- [ ] Tags and metadata survive renames on Windows
- [ ] No crashes or errors on FAT32/exFAT volumes
- [ ] File ID extraction success rate > 99% on NTFS
- [ ] No performance regression (File ID extraction is O(1))
## Platform Comparison
| Feature | Unix/Linux | macOS | Windows (current) | Windows (after) |
|---------|-----------|-------|-------------------|-----------------|
| Stable file identity | ✅ inode | ✅ inode | ❌ None | ✅ File ID |
| UUID preserved on rename | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes |
| Tags preserved on rename | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes |
| Implementation | `ino()` | `ino()` | `None` | `GetFileInformationByHandle` |
| Stability | ✅ Stable | ✅ Stable | N/A | ✅ Stable |
## Code Comment Updates
### Old comment (incorrect):
```rust
// Windows file indices exist but are unstable across reboots and
// volume operations, making them unsuitable for change detection.
```
### New comment (accurate):
```rust
// Windows NTFS File IDs provide stable file identification across renames
// and reboots within a volume. They use a 64-bit index similar to Unix inodes.
// File IDs are not available on FAT32/exFAT - we return None and fall back
// to path-based matching in those cases.
```
## References
- **Windows File Information API:**
https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfileinformationbyhandle
- **NTFS File System Architecture:**
https://docs.microsoft.com/en-us/windows/win32/fileio/file-management-functions
- **Rust Issue #63010** (file_index unstable):
https://github.com/rust-lang/rust/issues/63010
- **windows-sys crate:**
https://crates.io/crates/windows-sys
## Timeline Estimate
- **Implementation:** 2-3 hours (add dependency, write 30 lines of code)
- **Testing:** 1-2 hours (manual testing on NTFS, FAT32, edge cases)
- **Documentation:** 1 hour (update comments, add developer notes)
**Total:** 4-6 hours
## Priority Justification
**Medium Priority** because:
- ✅ System works without it (path-only fallback)
- ⚠️ Significant UX degradation on Windows (lost metadata on rename)
- ⚠️ Windows is a major platform for Spacedrive users
- ⚠️ Competitive gap (competitors handle this correctly)
**Should be elevated to High if:**
- User reports increase about lost tags/metadata on Windows
- Preparing major Windows release
- Windows becomes primary platform

BIN
Cargo.lock generated
View File

Binary file not shown.

View File

@@ -1,40 +1,109 @@
/// Spacedrive ASCII logo generated with oh-my-logo
/// Generated with: npx oh-my-logo "SPACEDRIVE" dawn --filled --no-color
pub const SPACEDRIVE_LOGO: &str = r#"
███████╗ ██████╗ █████╗ ██████╗ ███████╗ ██████╗ ██████╗ ██╗ ██╗ ██╗ ███████╗
██╔════╝ ██╔══██╗ ██╔══██╗ ██╔════╝ ██╔════╝ ██╔══██╗ ██╔══██╗ ██║ ██║ ██║ ██╔════╝
███████╗ ██████╔╝ ███████║ ██║ █████╗ ██║ ██║ ██████╔╝ ██║ ██║ ██║ █████╗
╚════██║ ██╔═══╝ ██╔══██║ ██║ ██╔══╝ ██║ ██║ ██╔══██╗ ██║ ╚██╗ ██╔╝ ██╔══╝
███████║ ██║ ██║ ██║ ╚██████╗ ███████╗ ██████╔╝ ██║ ██║ ██║ ╚████╔╝ ███████╗
╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝ ╚══════╝
"#;
/// Calculate brightness for a point on a sphere with lighting
fn calculate_sphere_brightness(x: f32, y: f32, radius: f32) -> Option<f32> {
let dx = x;
let dy = y;
let distance = (dx * dx + dy * dy).sqrt();
/// Print the Spacedrive logo with colors using ANSI escape codes
/// Colors using a light blue to purple gradient
pub fn print_logo_colored() {
// Light blue to purple gradient colors
let lines = SPACEDRIVE_LOGO.lines().collect::<Vec<_>>();
for (i, line) in lines.iter().enumerate() {
if line.trim().is_empty() {
println!();
continue;
}
// Create a gradient effect from light blue to purple
let color_code = match i % 6 {
0 => "\x1b[38;5;117m", // Light blue
1 => "\x1b[38;5;111m", // Sky blue
2 => "\x1b[38;5;105m", // Light purple-blue
3 => "\x1b[38;5;99m", // Medium purple
4 => "\x1b[38;5;93m", // Purple
_ => "\x1b[38;5;129m", // Deep purple
};
println!("{}{}\x1b[0m", color_code, line);
// Slightly reduce effective radius to avoid stray single pixels at edges
if distance > radius - 0.5 {
return None;
}
println!(" Cross-platform file management");
// Calculate z-coordinate on sphere surface
let z = (radius * radius - dx * dx - dy * dy).sqrt();
// Normal vector (pointing outward from sphere)
let nx = dx / radius;
let ny = dy / radius;
let nz = z / radius;
// Light from top-left-front
let lx: f32 = -0.4;
let ly: f32 = -0.3;
let lz: f32 = 0.8;
let light_len = (lx * lx + ly * ly + lz * lz).sqrt();
let lx = lx / light_len;
let ly = ly / light_len;
let lz = lz / light_len;
// Diffuse lighting
let diffuse = (nx * lx + ny * ly + nz * lz).max(0.0);
// Specular highlight
let view_z = 1.0;
let reflect_z = 2.0 * diffuse * nz - lz;
let specular = reflect_z.max(0.0).powf(20.0);
// Combine ambient, diffuse, and specular
let brightness = 0.2 + diffuse * 0.6 + specular * 0.8;
Some(brightness.min(1.0))
}
/// Get RGB color for purple gradient based on brightness
fn get_purple_color(brightness: f32) -> (u8, u8, u8) {
// Purple color palette - from dark to bright
let r = (80.0 + brightness * 175.0) as u8;
let g = (40.0 + brightness * 100.0) as u8;
let b = (120.0 + brightness * 135.0) as u8;
(r, g, b)
}
/// Print the Spacedrive logo as a purple orb using ANSI colors and Unicode half-blocks
pub fn print_logo_colored() {
let width = 36;
let height = 18;
let radius = 9.0;
let center_x = width as f32 / 2.0;
let center_y = height as f32 / 2.0;
println!();
// Render using half-blocks for 2x vertical resolution
for row in 0..height {
print!(" ");
for col in 0..width {
let x_pos = col as f32 - center_x;
// Top half of the character cell
let y_top = row as f32 * 2.0 - center_y;
let brightness_top = calculate_sphere_brightness(x_pos, y_top, radius);
// Bottom half of the character cell
let y_bottom = row as f32 * 2.0 + 1.0 - center_y;
let brightness_bottom = calculate_sphere_brightness(x_pos, y_bottom, radius);
match (brightness_top, brightness_bottom) {
(Some(b_top), Some(b_bottom)) => {
// Both halves are part of the sphere
let (r, g, b) = get_purple_color(b_top);
print!("\x1b[38;2;{};{};{}m", r, g, b);
let (r, g, b) = get_purple_color(b_bottom);
print!("\x1b[48;2;{};{};{}m", r, g, b);
print!("");
print!("\x1b[0m");
}
(Some(b_top), None) => {
// Only top half is sphere
let (r, g, b) = get_purple_color(b_top);
print!("\x1b[38;2;{};{};{}m▀\x1b[0m", r, g, b);
}
(None, Some(b_bottom)) => {
// Only bottom half is sphere
let (r, g, b) = get_purple_color(b_bottom);
print!("\x1b[38;2;{};{};{}m▄\x1b[0m", r, g, b);
}
(None, None) => {
// Neither half is sphere
print!(" ");
}
}
}
println!();
}
println!();
println!(" SPACEDRIVE");
println!();
}

View File

@@ -126,13 +126,20 @@ async function main() {
throw new Error(`Daemon binary not found at: ${DAEMON_BIN}`);
}
const depsLibPath = join(PROJECT_ROOT, "apps/.deps/lib");
const depsBinPath = join(PROJECT_ROOT, "apps/.deps/bin");
daemonProcess = spawn(DAEMON_BIN, ["--data-dir", DATA_DIR], {
cwd: PROJECT_ROOT,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
// On Windows DYLD_LIBRARY_PATH does nothing, but keeping it doesn't hurt
DYLD_LIBRARY_PATH: join(PROJECT_ROOT, "apps/.deps/lib"),
// macOS library path
DYLD_LIBRARY_PATH: depsLibPath,
// Windows: Add DLLs directory to PATH
PATH: IS_WIN
? `${depsBinPath};${process.env.PATH || ""}`
: process.env.PATH,
},
});

View File

@@ -209,6 +209,9 @@ vergen = { version = "8", features = ["cargo", "git", "gitcl"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.52", features = ["Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Security"] }
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
whisper-rs = { version = "0.15.1", features = ["metal"] }

View File

@@ -126,6 +126,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
accessed_at: Set(None),
indexed_at: Set(None),
permissions: Set(None),
device_id: Set(Some(inserted_device.id)),
inode: Set(None),
};
let entry_record = entry.insert(db.conn()).await?;

View File

@@ -204,7 +204,8 @@ impl crate::infra::sync::Syncable for Model {
let mut query = Entity::find();
// Filter by device ownership if specified (critical for device-owned data sync)
// Entries are owned via their location's device_id
// Entries now have device_id directly - use that instead of entry_closure during backfill
// to avoid circular dependency (entry_closure is rebuilt AFTER backfill)
if let Some(owner_device_uuid) = device_id {
// Get device's internal ID
let device = super::device::Entity::find()
@@ -213,18 +214,31 @@ impl crate::infra::sync::Syncable for Model {
.await?;
if let Some(dev) = device {
// Use raw SQL for device ownership filter (same proven pattern as get_device_owned_counts)
// Filter to only entries whose root location is owned by this device via entry_closure
use sea_orm::sea_query::SimpleExpr;
query = query.filter(
SimpleExpr::from(sea_orm::sea_query::Expr::cust_with_values::<&str, sea_orm::Value, Vec<sea_orm::Value>>(
"id IN (SELECT DISTINCT ec.descendant_id FROM entry_closure ec WHERE ec.ancestor_id IN (SELECT entry_id FROM locations WHERE device_id = ?))",
vec![dev.id.into()],
))
tracing::debug!(
device_uuid = %owner_device_uuid,
device_id = dev.id,
"Filtering entries by device_id"
);
// Check how many entries have this device_id for debugging
let count_with_device = Entity::find()
.filter(Column::DeviceId.eq(dev.id))
.count(db)
.await?;
tracing::debug!(
entries_with_device_id = count_with_device,
"Entries matching device_id before other filters"
);
// Filter by device_id directly (recently added to entry table)
// This avoids the circular dependency with entry_closure table
query = query.filter(Column::DeviceId.eq(dev.id));
} else {
// Device not found, return empty
tracing::warn!(
device_uuid = %owner_device_uuid,
"Device not found in database, returning empty"
);
return Ok(Vec::new());
}
}
@@ -257,6 +271,12 @@ impl crate::infra::sync::Syncable for Model {
let results = query.all(db).await?;
tracing::debug!(
result_count = results.len(),
batch_size = batch_size,
"Query executed, returning results"
);
// Batch lookup directory paths for all directories to avoid N+1 queries
let directory_ids: Vec<i32> = results
.iter()

View File

@@ -121,7 +121,8 @@ impl LocationManager {
indexed_at: Set(Some(now)), // Record when location root was created
permissions: Set(None),
inode: Set(None),
parent_id: Set(None), // Location root has no parent
parent_id: Set(None), // Location root has no parent
device_id: Set(Some(device_id)), // CRITICAL: Must be set for device-owned sync queries
..Default::default()
};

View File

@@ -192,7 +192,8 @@ pub async fn create_location(
indexed_at: Set(Some(now)), // CRITICAL: Must be set for sync to work (enables StateChange emission)
permissions: Set(None),
inode: Set(None),
parent_id: Set(None), // Location root has no parent
parent_id: Set(None), // Location root has no parent
device_id: Set(Some(device_id)), // CRITICAL: Must be set for device-owned sync queries
..Default::default()
};

View File

@@ -80,8 +80,12 @@ impl ChangeDetector {
.ok_or_else(|| JobError::execution("Location not found".to_string()))?;
// Create a persistent writer adapter to leverage the unified query logic
let persistence =
DatabaseAdapterForJob::new(ctx, location_record.uuid, location_record.entry_id);
let persistence = DatabaseAdapterForJob::new(
ctx,
location_record.uuid,
location_record.entry_id,
location_record.device_id,
);
// Use the scoped query method
let existing_entries = persistence.get_existing_entries(indexing_path).await?;

View File

@@ -32,6 +32,7 @@ pub struct DatabaseAdapter {
library_id: Uuid,
location_id: Uuid,
location_root_entry_id: i32,
device_id: i32,
db: sea_orm::DatabaseConnection,
volume_backend: Option<Arc<dyn crate::volume::VolumeBackend>>,
entry_id_cache: HashMap<PathBuf, i32>,
@@ -62,11 +63,14 @@ impl DatabaseAdapter {
.entry_id
.ok_or_else(|| anyhow::anyhow!("Location {} has no root entry", location_id))?;
let device_id = location_record.device_id;
Ok(Self {
context,
library_id,
location_id,
location_root_entry_id,
device_id,
db,
volume_backend,
entry_id_cache: HashMap::new(),
@@ -232,7 +236,7 @@ impl ChangeHandler for DatabaseAdapter {
&self.db,
library.as_deref(),
metadata,
0,
self.device_id,
parent_path,
)
.await
@@ -713,6 +717,7 @@ pub struct DatabaseAdapterForJob<'a> {
ctx: &'a JobContext<'a>,
library_id: Uuid,
location_root_entry_id: Option<i32>,
device_id: i32,
}
impl<'a> DatabaseAdapterForJob<'a> {
@@ -720,11 +725,13 @@ impl<'a> DatabaseAdapterForJob<'a> {
ctx: &'a JobContext<'a>,
library_id: Uuid,
location_root_entry_id: Option<i32>,
device_id: i32,
) -> Self {
Self {
ctx,
library_id,
location_root_entry_id,
device_id,
}
}
}
@@ -763,7 +770,7 @@ impl<'a> IndexPersistence for DatabaseAdapterForJob<'a> {
self.ctx.library_db(),
Some(self.ctx.library()),
entry,
0,
self.device_id,
location_root_path,
)
.await?;

View File

@@ -76,8 +76,9 @@ fn normalize_cloud_dir_path(path: &Path) -> PathBuf {
/// touching the database, while persistent indexing converts them to ActiveModels
/// in batch transactions.
///
/// The `inode` field is populated on Unix systems but remains `None` on Windows,
/// where file indices are unstable across reboots. Change detection uses
/// The `inode` field is populated on Unix/Linux/macOS and Windows NTFS filesystems
/// for stable file identification across renames. On Windows, this uses NTFS File IDs
/// (64-bit identifiers). On FAT32/exFAT, inode remains None. Change detection uses
/// (inode, mtime, size) tuples when available, falling back to path-only matching.
#[derive(Debug, Clone)]
pub struct EntryMetadata {
@@ -136,23 +137,94 @@ pub struct ContentLinkResult {
impl DatabaseStorage {
/// Get platform-specific inode
///
/// On Unix/Linux/macOS, extracts the inode number directly from metadata.
/// On Windows NTFS, opens the file to retrieve the 64-bit File ID via GetFileInformationByHandle.
/// Returns None on FAT32/exFAT filesystems or when file access fails.
#[cfg(unix)]
pub fn get_inode(metadata: &std::fs::Metadata) -> Option<u64> {
pub fn get_inode(_path: &Path, metadata: &std::fs::Metadata) -> Option<u64> {
use std::os::unix::fs::MetadataExt;
Some(metadata.ino())
}
#[cfg(windows)]
pub fn get_inode(_metadata: &std::fs::Metadata) -> Option<u64> {
// Windows file indices exist but are unstable across reboots and volume operations,
// making them unsuitable for change detection. We return None and fall back to
// path-only matching, which is sufficient since Windows NTFS doesn't support hard
// links for directories (the main inode use case on Unix).
None
pub fn get_inode(path: &Path, _metadata: &std::fs::Metadata) -> Option<u64> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_READ, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Storage::FileSystem::{
CreateFileW, GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
OPEN_EXISTING,
};
// Convert path to wide string for Windows API
let wide_path: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
// Use CreateFileW with FILE_FLAG_BACKUP_SEMANTICS to allow opening directories.
// std::fs::File::open fails for directories on Windows without this flag.
let handle = unsafe {
CreateFileW(
wide_path.as_ptr(),
GENERIC_READ,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
std::ptr::null_mut(),
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS, // Required to open directories
0,
)
};
if handle == INVALID_HANDLE_VALUE {
tracing::debug!(
"Failed to open path for File ID extraction: {}",
path.display()
);
return None;
}
let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
let result = unsafe {
if GetFileInformationByHandle(handle, &mut info) != 0 {
// Combine high and low 32-bit values into 64-bit File ID
let file_id = ((info.nFileIndexHigh as u64) << 32) | (info.nFileIndexLow as u64);
// File ID of 0 indicates FAT32/exFAT (no File ID support)
if file_id == 0 {
tracing::debug!(
"File ID is 0 for {:?} (likely FAT32/exFAT filesystem)",
path.file_name().unwrap_or_default()
);
None
} else {
tracing::trace!(
"Extracted File ID: 0x{:016X} for {:?}",
file_id,
path.file_name().unwrap_or_default()
);
Some(file_id)
}
} else {
// GetFileInformationByHandle failed
// Common reasons: FAT32/exFAT filesystem, permission denied
tracing::debug!(
"GetFileInformationByHandle failed for {:?} (likely FAT32/exFAT or permission issue)",
path.file_name().unwrap_or_default()
);
None
}
};
// Always close the handle
unsafe {
CloseHandle(handle);
}
result
}
#[cfg(not(any(unix, windows)))]
pub fn get_inode(_metadata: &std::fs::Metadata) -> Option<u64> {
pub fn get_inode(_path: &Path, _metadata: &std::fs::Metadata) -> Option<u64> {
None
}
@@ -236,7 +308,7 @@ impl DatabaseStorage {
EntryKind::File
};
let inode = Self::get_inode(&metadata);
let inode = Self::get_inode(path, &metadata);
#[cfg(unix)]
let permissions = {

View File

@@ -303,7 +303,7 @@ impl ChangeHandler for MemoryAdapter {
modified: metadata.modified().ok(),
accessed: metadata.accessed().ok(),
created: metadata.created().ok(),
inode: DatabaseStorage::get_inode(&metadata),
inode: DatabaseStorage::get_inode(&entry_path, &metadata),
permissions: None,
is_hidden: entry_path
.file_name()

View File

@@ -773,7 +773,7 @@ impl IndexerJob {
kind: entry_kind,
size: metadata.len(),
modified: metadata.modified().ok(),
inode: DatabaseStorage::get_inode(&metadata),
inode: DatabaseStorage::get_inode(&path, &metadata),
};
state.pending_entries.push(dir_entry);

View File

@@ -84,6 +84,7 @@ impl PersistenceFactory {
ctx: &'a crate::infra::job::prelude::JobContext<'a>,
library_id: uuid::Uuid,
location_root_entry_id: Option<i32>,
device_id: i32,
) -> Box<dyn IndexPersistence + 'a> {
use crate::ops::indexing::change_detection::DatabaseAdapterForJob;
@@ -91,6 +92,7 @@ impl PersistenceFactory {
ctx,
library_id,
location_root_entry_id,
device_id,
))
}

View File

@@ -146,9 +146,12 @@ fn accept_by_git_pattern(
Ok(p) => p,
Err(_) => return true,
};
let Some(src) = relative.to_str().map(|s| s.as_bytes().into()) else {
let Some(path_str) = relative.to_str() else {
return false;
};
// Gitignore patterns expect forward slashes, even on Windows
let normalized_path = path_str.replace('\\', "/");
let src = normalized_path.as_bytes().into();
search
.pattern_matching_relative_path(src, Some(source.is_dir()), Case::Fold)
.map_or(true, |rule| rule.pattern.is_negative())
@@ -545,6 +548,12 @@ pub static NO_SYSTEM_FILES: Lazy<SystemIndexerRule> = Lazy::new(|| {
RulePerKind::new_reject_files_by_globs_str(
[
vec!["**/.spacedrive"],
// Cross-platform: macOS metadata files that can appear on any OS (network shares, USB drives, etc.)
vec![
"**/.{DS_Store,AppleDouble,LSOverride}",
"**/Icon\r\r",
"**/._*",
],
#[cfg(target_os = "windows")]
vec![
"**/{Thumbs.db,Thumbs.db:encryptable,ehthumbs.db,ehthumbs_vista.db}",
@@ -564,12 +573,6 @@ pub static NO_SYSTEM_FILES: Lazy<SystemIndexerRule> = Lazy::new(|| {
"[A-Z]:/swapfile.sys",
"C:/DumpStack.log.tmp",
],
#[cfg(any(target_os = "ios", target_os = "macos"))]
vec![
"**/.{DS_Store,AppleDouble,LSOverride}",
"**/Icon\r\r",
"**/._*",
],
#[cfg(target_os = "macos")]
vec![
"/{System,Network,Library,Applications,.PreviousSystemInformation,.com.apple.templatemigration.boot-install}",

View File

@@ -38,21 +38,74 @@ impl LocalBackend {
}
/// Extract inode from metadata (platform-specific)
///
/// On Unix/Linux/macOS, extracts the inode number directly from metadata.
/// On Windows NTFS, opens the file to retrieve the 64-bit File ID.
/// Returns None on FAT32/exFAT filesystems or when file access fails.
#[cfg(unix)]
fn get_inode(metadata: &std::fs::Metadata) -> Option<u64> {
fn get_inode(_path: &Path, metadata: &std::fs::Metadata) -> Option<u64> {
use std::os::unix::fs::MetadataExt;
Some(metadata.ino())
}
#[cfg(windows)]
fn get_inode(_metadata: &std::fs::Metadata) -> Option<u64> {
// Windows 'file_index' is unstable (issue #63010).
// Returning None is safe as the field is Optional.
None
fn get_inode(path: &Path, _metadata: &std::fs::Metadata) -> Option<u64> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_READ, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Storage::FileSystem::{
CreateFileW, GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
OPEN_EXISTING,
};
// Convert path to wide string for Windows API
let wide_path: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
// Use CreateFileW with FILE_FLAG_BACKUP_SEMANTICS to allow opening directories.
// std::fs::File::open fails for directories on Windows without this flag.
let handle = unsafe {
CreateFileW(
wide_path.as_ptr(),
GENERIC_READ,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
std::ptr::null_mut(),
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS, // Required to open directories
0,
)
};
if handle == INVALID_HANDLE_VALUE {
return None; // Failed to open path (e.g., permission denied)
}
let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
let result = unsafe {
if GetFileInformationByHandle(handle, &mut info) != 0 {
let file_id = ((info.nFileIndexHigh as u64) << 32) | (info.nFileIndexLow as u64);
// File ID of 0 indicates FAT32/exFAT (no File ID support)
if file_id == 0 {
None
} else {
Some(file_id)
}
} else {
None // GetFileInformationByHandle failed
}
};
// Always close the handle
unsafe {
CloseHandle(handle);
}
result
}
#[cfg(not(any(unix, windows)))]
fn get_inode(_metadata: &std::fs::Metadata) -> Option<u64> {
fn get_inode(_path: &Path, _metadata: &std::fs::Metadata) -> Option<u64> {
None
}
}
@@ -141,12 +194,14 @@ impl VolumeBackend for LocalBackend {
EntryKind::File
};
let entry_path = entry.path();
entries.push(RawDirEntry {
name: entry.file_name().to_string_lossy().to_string(),
kind,
size: metadata.len(),
modified: metadata.modified().ok(),
inode: Self::get_inode(&metadata),
inode: Self::get_inode(&entry_path, &metadata),
});
}
@@ -184,7 +239,7 @@ impl VolumeBackend for LocalBackend {
modified: metadata.modified().ok(),
created: metadata.created().ok(),
accessed: metadata.accessed().ok(),
inode: Self::get_inode(&metadata),
inode: Self::get_inode(&full_path, &metadata),
permissions,
})
}

View File

@@ -53,12 +53,9 @@ async fn find_entry_by_name(
async fn test_entry_metadata_preservation_on_move() {
println!("Starting entry metadata preservation test");
// 1. Clean slate - delete entire data directory first
let data_dir = std::path::PathBuf::from("core/data/move-integrity-test");
if data_dir.exists() {
std::fs::remove_dir_all(&data_dir).unwrap();
println!("Deleted existing data directory for clean test");
}
// 1. Clean slate - use temp directory
let temp_data = TempDir::new().unwrap();
let data_dir = temp_data.path().join("core_data");
std::fs::create_dir_all(&data_dir).unwrap();
println!("Created fresh data directory: {:?}", data_dir);
@@ -384,11 +381,9 @@ async fn test_entry_metadata_preservation_on_move() {
async fn test_child_entry_metadata_preservation_on_parent_move() {
println!("Starting child entry metadata preservation test");
// Setup similar to main test - use same persistent database
let data_dir = std::path::PathBuf::from("core/data/spacedrive-search-demo");
if data_dir.exists() {
std::fs::remove_dir_all(&data_dir).unwrap();
}
// Setup similar to main test - use temp directory
let temp_data = TempDir::new().unwrap();
let data_dir = temp_data.path().join("core_data");
std::fs::create_dir_all(&data_dir).unwrap();
let core = Arc::new(Core::new(data_dir.clone()).await.unwrap());

View File

@@ -3,7 +3,9 @@
//! Provides reusable components for indexing integration tests,
//! reducing boilerplate and making it easy to test change detection.
use super::{init_test_tracing, register_device, wait_for_indexing, TestConfigBuilder};
use super::{
init_test_tracing, register_device, wait_for_indexing, TestConfigBuilder, TestDataDir,
};
use anyhow::Context;
use sd_core::{
infra::db::entities::{self, entry_closure},
@@ -15,7 +17,6 @@ use std::{
path::{Path, PathBuf},
sync::Arc,
};
use tempfile::TempDir;
use tokio::time::Duration;
use uuid::Uuid;
@@ -50,13 +51,9 @@ impl IndexingHarnessBuilder {
/// Build the harness
pub async fn build(self) -> anyhow::Result<IndexingHarness> {
// Use home directory for proper filesystem watcher support on macOS
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let test_root = PathBuf::from(home).join(format!(".spacedrive_test_{}", self.test_name));
// Clean up any existing test directory
let _ = tokio::fs::remove_dir_all(&test_root).await;
tokio::fs::create_dir_all(&test_root).await?;
// Use TestDataDir with watcher support (uses home directory for macOS compatibility)
let test_data = TestDataDir::new_for_watcher(&self.test_name)?;
let test_root = test_data.path().to_path_buf();
let snapshot_dir = test_root.join("snapshots");
tokio::fs::create_dir_all(&snapshot_dir).await?;
@@ -90,7 +87,8 @@ impl IndexingHarnessBuilder {
// Use the real device UUID so the watcher can find locations
let device_id = sd_core::device::get_current_device_id();
let device_name = whoami::devicename();
// Make device name unique per test to avoid slug collisions in parallel tests
let device_name = format!("{}-{}", whoami::devicename(), self.test_name);
register_device(&library, device_id, &device_name).await?;
// Get device record
@@ -134,8 +132,7 @@ impl IndexingHarnessBuilder {
};
Ok(IndexingHarness {
_test_name: self.test_name,
_test_root: test_root,
test_data,
snapshot_dir,
core,
library,
@@ -148,8 +145,7 @@ impl IndexingHarnessBuilder {
/// Indexing test harness with convenient helper methods
pub struct IndexingHarness {
_test_name: String,
_test_root: PathBuf,
test_data: TestDataDir,
pub snapshot_dir: PathBuf,
pub core: Arc<Core>,
pub library: Arc<sd_core::library::Library>,
@@ -161,7 +157,12 @@ pub struct IndexingHarness {
impl IndexingHarness {
/// Get the temp directory path (for creating test files)
pub fn temp_path(&self) -> &Path {
&self._test_root
self.test_data.path()
}
/// Get access to the snapshot manager (if snapshots enabled via SD_TEST_SNAPSHOTS=1)
pub fn snapshot_manager(&self) -> Option<&super::SnapshotManager> {
self.test_data.snapshot_manager()
}
/// Get the daemon socket address (only available if daemon is enabled)
@@ -266,7 +267,6 @@ impl IndexingHarness {
/// Shutdown the harness
pub async fn shutdown(self) -> anyhow::Result<()> {
let lib_id = self.library.id();
let test_root = self._test_root.clone();
self.core.libraries.close_library(lib_id).await?;
drop(self.library);
@@ -275,9 +275,14 @@ impl IndexingHarness {
.await
.map_err(|e| anyhow::anyhow!("Failed to shutdown core: {}", e))?;
// Clean up test directory
tokio::fs::remove_dir_all(&test_root).await?;
// On Windows, SQLite file locks can persist after shutdown even after WAL checkpoint
// This is due to the connection pool in SeaORM potentially holding onto connections
// Give the OS time to release locks to reduce leftover test directories
// TestDataDir cleanup ignores errors on Windows, so this is just best-effort
#[cfg(windows)]
tokio::time::sleep(Duration::from_millis(500)).await;
// TestDataDir handles cleanup automatically on drop
Ok(())
}
}
@@ -421,19 +426,47 @@ impl<'a> LocationHandle<'a> {
/// Verify entries with inodes
pub async fn verify_inode_tracking(&self) -> anyhow::Result<()> {
let entry_ids = self.get_all_entry_ids().await?;
let entries_with_inodes = entities::entry::Entity::find()
.filter(entities::entry::Column::Id.is_in(entry_ids))
.filter(entities::entry::Column::Inode.is_not_null())
.count(self.harness.library.db().conn())
.await?;
// Windows NTFS File IDs are now supported. On FAT32/exFAT filesystems,
// File IDs are not available, so we skip verification if no inodes are found.
#[cfg(windows)]
{
let entry_ids = self.get_all_entry_ids().await?;
let entries_with_inodes = entities::entry::Entity::find()
.filter(entities::entry::Column::Id.is_in(entry_ids))
.filter(entities::entry::Column::Inode.is_not_null())
.count(self.harness.library.db().conn())
.await?;
anyhow::ensure!(
entries_with_inodes > 0,
"At least some entries should have inode tracking"
);
if entries_with_inodes == 0 {
tracing::warn!(
"No entries with File IDs found - likely FAT32/exFAT filesystem. Skipping inode verification."
);
return Ok(());
}
Ok(())
tracing::debug!(
"Windows File ID tracking verified: {} entries have File IDs",
entries_with_inodes
);
return Ok(());
}
#[cfg(not(windows))]
{
let entry_ids = self.get_all_entry_ids().await?;
let entries_with_inodes = entities::entry::Entity::find()
.filter(entities::entry::Column::Id.is_in(entry_ids))
.filter(entities::entry::Column::Inode.is_not_null())
.count(self.harness.library.db().conn())
.await?;
anyhow::ensure!(
entries_with_inodes > 0,
"At least some entries should have inode tracking"
);
Ok(())
}
}
/// Write a new file to the location

View File

@@ -2,11 +2,15 @@
pub mod event_collector;
pub mod indexing_harness;
pub mod snapshot;
pub mod sync_harness;
pub mod sync_transport;
pub mod test_data;
pub mod test_volumes;
pub use event_collector::*;
pub use indexing_harness::*;
pub use snapshot::*;
pub use sync_harness::*;
pub use sync_transport::*;
pub use test_data::*;

View File

@@ -0,0 +1,270 @@
//! Test snapshot management for preserving test state
use chrono::Utc;
use std::{
fs,
path::{Path, PathBuf},
sync::atomic::{AtomicBool, Ordering},
};
/// Manages test snapshots for post-mortem debugging
pub struct SnapshotManager {
test_name: String,
test_data_path: PathBuf,
snapshot_base_path: PathBuf,
timestamp: String,
captured: AtomicBool,
}
impl SnapshotManager {
/// Create new snapshot manager
///
/// Snapshots are stored in platform-appropriate location:
/// - macOS: ~/Library/Application Support/spacedrive/test_snapshots/
/// - Linux: ~/.local/share/spacedrive/test_snapshots/
/// - Windows: %APPDATA%\spacedrive\test_snapshots\
pub fn new(test_name: &str, test_data_path: &Path) -> anyhow::Result<Self> {
let snapshot_base = Self::get_snapshot_base_path()?;
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
Ok(Self {
test_name: test_name.to_string(),
test_data_path: test_data_path.to_path_buf(),
snapshot_base_path: snapshot_base,
timestamp,
captured: AtomicBool::new(false),
})
}
/// Get platform-appropriate snapshot base path
fn get_snapshot_base_path() -> anyhow::Result<PathBuf> {
let base = if cfg!(target_os = "macos") {
let home = std::env::var("HOME")?;
PathBuf::from(home).join("Library/Application Support/spacedrive/test_snapshots")
} else if cfg!(target_os = "windows") {
let appdata = std::env::var("APPDATA")?;
PathBuf::from(appdata).join("spacedrive\\test_snapshots")
} else {
// Linux and other Unix-like systems
let home = std::env::var("HOME")?;
PathBuf::from(home).join(".local/share/spacedrive/test_snapshots")
};
fs::create_dir_all(&base)?;
Ok(base)
}
/// Capture snapshot with optional label (e.g., "after_phase_1")
pub async fn capture(&self, label: impl Into<String>) -> anyhow::Result<PathBuf> {
let label = label.into();
let snapshot_path = self
.snapshot_base_path
.join(&self.test_name)
.join(format!("{}_{}", self.timestamp, label));
self.capture_to_path(&snapshot_path).await?;
self.captured.store(true, Ordering::SeqCst);
Ok(snapshot_path)
}
/// Capture final snapshot (called automatically on drop if not already captured)
pub async fn capture_final(&self) -> anyhow::Result<PathBuf> {
self.capture("final").await
}
/// Capture final snapshot using blocking operations (for use in Drop)
pub(crate) fn capture_final_blocking(&self) -> anyhow::Result<PathBuf> {
let snapshot_path = self
.snapshot_base_path
.join(&self.test_name)
.join(format!("{}_final", self.timestamp));
self.capture_to_path_blocking(&snapshot_path)?;
self.captured.store(true, Ordering::SeqCst);
Ok(snapshot_path)
}
/// Check if snapshot has been captured
pub fn captured(&self) -> bool {
self.captured.load(Ordering::SeqCst)
}
/// Get snapshot path for this test run
pub fn snapshot_path(&self) -> PathBuf {
self.snapshot_base_path
.join(&self.test_name)
.join(&self.timestamp)
}
/// Async capture to path
async fn capture_to_path(&self, snapshot_path: &Path) -> anyhow::Result<()> {
tokio::fs::create_dir_all(snapshot_path).await?;
// Copy core_data directory (databases, etc.)
let core_data_src = self.test_data_path.join("core_data");
if tokio::fs::try_exists(&core_data_src).await.unwrap_or(false) {
let core_data_dst = snapshot_path.join("core_data");
self.copy_dir_async(&core_data_src, &core_data_dst).await?;
}
// Copy logs directory
let logs_src = self.test_data_path.join("logs");
if tokio::fs::try_exists(&logs_src).await.unwrap_or(false) {
let logs_dst = snapshot_path.join("logs");
self.copy_dir_async(&logs_src, &logs_dst).await?;
}
// Write summary
self.write_summary(snapshot_path).await?;
Ok(())
}
/// Blocking capture to path (for use in Drop)
fn capture_to_path_blocking(&self, snapshot_path: &Path) -> anyhow::Result<()> {
fs::create_dir_all(snapshot_path)?;
// Copy core_data directory (databases, etc.)
let core_data_src = self.test_data_path.join("core_data");
if core_data_src.exists() {
let core_data_dst = snapshot_path.join("core_data");
self.copy_dir_blocking(&core_data_src, &core_data_dst)?;
}
// Copy logs directory
let logs_src = self.test_data_path.join("logs");
if logs_src.exists() {
let logs_dst = snapshot_path.join("logs");
self.copy_dir_blocking(&logs_src, &logs_dst)?;
}
// Write summary
self.write_summary_blocking(snapshot_path)?;
Ok(())
}
/// Recursively copy directory (async)
fn copy_dir_async<'a>(
&'a self,
src: &'a Path,
dst: &'a Path,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + 'a>> {
Box::pin(async move {
tokio::fs::create_dir_all(dst).await?;
let mut entries = tokio::fs::read_dir(src).await?;
while let Some(entry) = entries.next_entry().await? {
let ty = entry.file_type().await?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if ty.is_dir() {
self.copy_dir_async(&src_path, &dst_path).await?;
} else {
tokio::fs::copy(&src_path, &dst_path).await?;
}
}
Ok(())
})
}
/// Recursively copy directory (blocking)
fn copy_dir_blocking(&self, src: &Path, dst: &Path) -> anyhow::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if ty.is_dir() {
self.copy_dir_blocking(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
/// Write summary markdown (async)
async fn write_summary(&self, snapshot_path: &Path) -> anyhow::Result<()> {
let summary = self.generate_summary(snapshot_path)?;
tokio::fs::write(snapshot_path.join("summary.md"), summary).await?;
Ok(())
}
/// Write summary markdown (blocking)
fn write_summary_blocking(&self, snapshot_path: &Path) -> anyhow::Result<()> {
let summary = self.generate_summary(snapshot_path)?;
fs::write(snapshot_path.join("summary.md"), summary)?;
Ok(())
}
/// Generate summary content
fn generate_summary(&self, snapshot_path: &Path) -> anyhow::Result<String> {
let mut summary = String::new();
summary.push_str(&format!("# Test Snapshot: {}\n\n", self.test_name));
summary.push_str(&format!(
"**Timestamp**: {}\n",
Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
));
summary.push_str(&format!("**Test**: {}\n\n", self.test_name));
summary.push_str("## Snapshot Contents\n\n");
// List files in snapshot
let files = self.list_snapshot_files(snapshot_path)?;
for file in files {
let metadata = fs::metadata(snapshot_path.join(&file))?;
let size = if metadata.is_file() {
format!(" ({} bytes)", metadata.len())
} else {
" (directory)".to_string()
};
summary.push_str(&format!("- {}{}\n", file, size));
}
summary.push_str("\n## Test Data Location\n\n");
summary.push_str(&format!(
"Temp directory: {}\n",
self.test_data_path.display()
));
Ok(summary)
}
/// List all files in snapshot recursively
fn list_snapshot_files(&self, path: &Path) -> anyhow::Result<Vec<String>> {
let mut files = Vec::new();
self.list_files_recursive(path, path, &mut files)?;
files.sort();
Ok(files)
}
fn list_files_recursive(
&self,
base: &Path,
current: &Path,
files: &mut Vec<String>,
) -> anyhow::Result<()> {
for entry in fs::read_dir(current)? {
let entry = entry?;
let path = entry.path();
let relative = path.strip_prefix(base)?.to_string_lossy().to_string();
files.push(relative.clone());
if entry.file_type()?.is_dir() {
self.list_files_recursive(base, &path, files)?;
}
}
Ok(())
}
}

View File

@@ -256,6 +256,10 @@ pub async fn wait_for_indexing(
let completed_jobs = library.jobs().list_jobs(Some(JobStatus::Completed)).await?;
if !completed_jobs.is_empty() {
job_seen = true;
}
if job_seen && !completed_jobs.is_empty() && running_jobs.is_empty() && current_entries > 0
{
if current_entries == last_entry_count {
@@ -708,6 +712,7 @@ impl SnapshotCapture {
#[allow(dead_code)]
pub struct TwoDeviceHarnessBuilder {
test_name: String,
test_data: super::TestDataDir,
data_dir_alice: PathBuf,
data_dir_bob: PathBuf,
snapshot_dir: PathBuf,
@@ -719,10 +724,11 @@ pub struct TwoDeviceHarnessBuilder {
#[allow(dead_code)]
impl TwoDeviceHarnessBuilder {
pub async fn new(test_name: impl Into<String>) -> anyhow::Result<Self> {
let test_name = test_name.into();
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let test_root = std::path::PathBuf::from(home)
.join("Library/Application Support/spacedrive/sync_tests");
let test_name_str = test_name.into();
// Use TestDataDir for proper temp directory management
let test_data = super::TestDataDir::new(&test_name_str)?;
let test_root = test_data.path().to_path_buf();
let data_dir = test_root.join("data");
fs::create_dir_all(&data_dir).await?;
@@ -732,10 +738,12 @@ impl TwoDeviceHarnessBuilder {
fs::create_dir_all(&temp_dir_alice).await?;
fs::create_dir_all(&temp_dir_bob).await?;
let snapshot_dir = create_snapshot_dir(&test_name).await?;
let snapshot_dir = test_root.join("snapshots");
fs::create_dir_all(&snapshot_dir).await?;
Ok(Self {
test_name,
test_name: test_name_str,
test_data,
data_dir_alice: temp_dir_alice,
data_dir_bob: temp_dir_bob,
snapshot_dir,
@@ -905,6 +913,7 @@ impl TwoDeviceHarnessBuilder {
};
Ok(TwoDeviceHarness {
test_data: self.test_data,
data_dir_alice: self.data_dir_alice,
data_dir_bob: self.data_dir_bob,
core_alice,
@@ -926,6 +935,7 @@ impl TwoDeviceHarnessBuilder {
/// Two-device sync test harness
pub struct TwoDeviceHarness {
test_data: super::TestDataDir,
pub data_dir_alice: PathBuf,
pub data_dir_bob: PathBuf,
pub core_alice: Core,
@@ -944,6 +954,19 @@ pub struct TwoDeviceHarness {
}
impl TwoDeviceHarness {
/// Get access to the snapshot manager (if snapshots enabled via SD_TEST_SNAPSHOTS=1)
pub fn snapshot_manager(&self) -> Option<&super::SnapshotManager> {
self.test_data.snapshot_manager()
}
/// Capture snapshot with label (convenience method)
pub async fn capture_snapshot(&self, label: &str) -> anyhow::Result<()> {
if let Some(manager) = self.snapshot_manager() {
manager.capture(label).await?;
}
Ok(())
}
/// Wait for sync to complete using the sophisticated algorithm
pub async fn wait_for_sync(&self, max_duration: Duration) -> anyhow::Result<()> {
wait_for_sync(&self.library_alice, &self.library_bob, max_duration).await
@@ -962,109 +985,6 @@ impl TwoDeviceHarness {
pub async fn add_and_index_location_bob(&self, path: &str, name: &str) -> anyhow::Result<Uuid> {
add_and_index_location(&self.library_bob, path, name).await
}
/// Capture comprehensive snapshot
pub async fn capture_snapshot(&self, scenario_name: &str) -> anyhow::Result<PathBuf> {
let snapshot_path = self.snapshot_dir.join(scenario_name);
fs::create_dir_all(&snapshot_path).await?;
tracing::info!(
scenario = scenario_name,
path = %snapshot_path.display(),
"Capturing snapshot"
);
let capture = SnapshotCapture::new(snapshot_path.clone());
// Copy Alice's data
capture
.copy_database(&self.library_alice, "alice", "database.db")
.await?;
capture
.copy_database(&self.library_alice, "alice", "sync.db")
.await?;
capture.copy_logs(&self.library_alice, "alice").await?;
if let Some(events) = &self.event_log_alice {
let events = events.lock().await;
capture
.write_event_log(&events, "alice", "events.log")
.await?;
}
if let Some(sync_events) = &self.sync_event_log_alice {
let events = sync_events.lock().await;
capture
.write_sync_event_log(&events, "alice", "sync_events.log")
.await?;
}
// Copy Bob's data
capture
.copy_database(&self.library_bob, "bob", "database.db")
.await?;
capture
.copy_database(&self.library_bob, "bob", "sync.db")
.await?;
capture.copy_logs(&self.library_bob, "bob").await?;
if let Some(events) = &self.event_log_bob {
let events = events.lock().await;
capture
.write_event_log(&events, "bob", "events.log")
.await?;
}
if let Some(sync_events) = &self.sync_event_log_bob {
let events = sync_events.lock().await;
capture
.write_sync_event_log(&events, "bob", "sync_events.log")
.await?;
}
// Write summary
let alice_events = self
.event_log_alice
.as_ref()
.map(|e| e.blocking_lock().len())
.unwrap_or(0);
let bob_events = self
.event_log_bob
.as_ref()
.map(|e| e.blocking_lock().len())
.unwrap_or(0);
let alice_sync_events = self
.sync_event_log_alice
.as_ref()
.map(|e| e.blocking_lock().len())
.unwrap_or(0);
let bob_sync_events = self
.sync_event_log_bob
.as_ref()
.map(|e| e.blocking_lock().len())
.unwrap_or(0);
capture
.write_summary(
scenario_name,
&self.library_alice,
&self.library_bob,
self.device_alice_id,
self.device_bob_id,
alice_events,
bob_events,
alice_sync_events,
bob_sync_events,
)
.await?;
tracing::info!(
snapshot_path = %snapshot_path.display(),
"Snapshot captured"
);
Ok(snapshot_path)
}
}
/// Start event collector for main event bus

View File

@@ -0,0 +1,218 @@
//! Test data directory management with automatic cleanup and snapshot support
use super::snapshot::SnapshotManager;
use std::path::{Path, PathBuf};
/// Manages test data directories with automatic cleanup and optional snapshot support
pub struct TestDataDir {
test_name: String,
temp_path: PathBuf,
snapshot_manager: Option<SnapshotManager>,
}
impl TestDataDir {
/// Create new test data directory in system temp location
///
/// Directory structure:
/// ```
/// /tmp/spacedrive-test-{test_name}/
/// ├── core_data/ # Core database and state
/// ├── locations/ # Test file locations
/// └── logs/ # Test execution logs
/// ```
///
/// Snapshots are enabled if SD_TEST_SNAPSHOTS=1 environment variable is set.
pub fn new(test_name: impl Into<String>) -> anyhow::Result<Self> {
Self::with_mode(test_name, false)
}
/// Create test data directory with filesystem watcher support
///
/// Uses home directory instead of temp on macOS because temp directories
/// don't reliably deliver filesystem events. This is required for tests
/// that use the filesystem watcher.
pub fn new_for_watcher(test_name: impl Into<String>) -> anyhow::Result<Self> {
Self::with_mode(test_name, true)
}
fn with_mode(test_name: impl Into<String>, use_home_for_watcher: bool) -> anyhow::Result<Self> {
let test_name = test_name.into();
// Choose base directory based on watcher requirements
let temp_base = if use_home_for_watcher {
// Use home directory for watcher support (macOS temp doesn't deliver events)
if cfg!(windows) {
std::env::var("USERPROFILE").unwrap_or_else(|_| {
std::env::var("TEMP").unwrap_or_else(|_| "C:\\temp".to_string())
})
} else {
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string())
}
} else {
// Use temp directory for regular tests
if cfg!(windows) {
std::env::var("TEMP").unwrap_or_else(|_| "C:\\temp".to_string())
} else {
"/tmp".to_string()
}
};
// On Windows, add timestamp + counter to ensure uniqueness even across test runs
// This prevents conflicts with leftover files from previous runs where cleanup failed
let dir_name = if use_home_for_watcher {
#[cfg(windows)]
{
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
format!(".spacedrive_test_{}_{}_{}", test_name, timestamp, id)
}
#[cfg(not(windows))]
{
format!(".spacedrive_test_{}", test_name)
}
} else {
#[cfg(windows)]
{
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
format!("spacedrive-test-{}-{}-{}", test_name, timestamp, id)
}
#[cfg(not(windows))]
{
format!("spacedrive-test-{}", test_name)
}
};
let temp_path = PathBuf::from(temp_base).join(dir_name);
// On Windows, try to clean up any leftover directory from failed previous cleanup
// Retry a few times since file locks may be released shortly
#[cfg(windows)]
{
for attempt in 0..3 {
match std::fs::remove_dir_all(&temp_path) {
Ok(_) => break,
Err(e) if attempt < 2 => {
std::thread::sleep(std::time::Duration::from_millis(100));
}
Err(_) => {
// After retries, ignore - we have unique timestamp+counter so no conflict
break;
}
}
}
}
#[cfg(not(windows))]
{
let _ = std::fs::remove_dir_all(&temp_path);
}
std::fs::create_dir_all(&temp_path)?;
// Create standard subdirectories
std::fs::create_dir_all(temp_path.join("core_data"))?;
std::fs::create_dir_all(temp_path.join("locations"))?;
std::fs::create_dir_all(temp_path.join("logs"))?;
// Check if snapshots are enabled
let snapshot_enabled = std::env::var("SD_TEST_SNAPSHOTS")
.map(|v| v == "1" || v.to_lowercase() == "true")
.unwrap_or(false);
let snapshot_manager = if snapshot_enabled {
Some(SnapshotManager::new(&test_name, &temp_path)?)
} else {
None
};
Ok(Self {
test_name,
temp_path,
snapshot_manager,
})
}
/// Get path to temp directory root
pub fn path(&self) -> &Path {
&self.temp_path
}
/// Get path for core data (database, preferences, etc.)
pub fn core_data_path(&self) -> PathBuf {
self.temp_path.join("core_data")
}
/// Get path for test locations
pub fn locations_path(&self) -> PathBuf {
self.temp_path.join("locations")
}
/// Get path for test logs
pub fn logs_path(&self) -> PathBuf {
self.temp_path.join("logs")
}
/// Check if snapshots are enabled
pub fn snapshots_enabled(&self) -> bool {
self.snapshot_manager.is_some()
}
/// Get snapshot manager (if snapshots enabled)
pub fn snapshot_manager(&self) -> Option<&SnapshotManager> {
self.snapshot_manager.as_ref()
}
/// Get mutable snapshot manager (if snapshots enabled)
pub fn snapshot_manager_mut(&mut self) -> Option<&mut SnapshotManager> {
self.snapshot_manager.as_mut()
}
/// Get test name
pub fn test_name(&self) -> &str {
&self.test_name
}
}
impl Drop for TestDataDir {
fn drop(&mut self) {
// Capture final snapshot if enabled and not already captured
if let Some(manager) = &mut self.snapshot_manager {
if !manager.captured() {
// Use blocking operation in drop
let _ = manager.capture_final_blocking();
}
}
// Clean up temp directory
// On Windows, SQLite file locks may persist even after shutdown, causing
// removal to fail. Since tests use unique directories (via atomic counter),
// we can safely ignore cleanup failures. Windows will clean temp directories
// periodically.
if let Err(e) = std::fs::remove_dir_all(&self.temp_path) {
#[cfg(windows)]
{
// Silently ignore on Windows - file locks are expected
let _ = e;
}
#[cfg(not(windows))]
{
eprintln!(
"Warning: Failed to clean up test directory {:?}: {}",
self.temp_path, e
);
}
}
}
}

View File

@@ -1,504 +1,74 @@
//! Test to reproduce ghost folder bug when moving folders into managed locations
//! Watcher integration test for moving folders into managed locations
//!
//! This test reproduces the issue where moving a folder into a managed location
//! triggers a reindex that creates duplicate entries with wrong parent_ids.
//!
//! ## Bug Description
//! When a folder is moved from outside a managed location into it (e.g., moving
//! Desk1 into Desktop), the watcher triggers a reindex at that subpath. During
//! this reindex, entries are created with incorrect parent_id values, pointing
//! to the location root instead of their actual parent directory.
//!
//! ## Expected Behavior
//! - Desk/ moved into Desktop/
//! - Desk/Subfolder/ should have parent_id = Desk1's entry ID
//!
//! ## Actual Behavior
//! - Desk/Subfolder/ gets parent_id = Desktop's entry ID (wrong!)
//! - Creates "ghost folders" that appear at Desktop root in API but don't exist there
//!
//! ## Running Test
//! ```bash
//! cargo test -p sd-core --test indexing_move_folder_bug_test -- --nocapture
//! ```
//! Verifies that when a folder tree is moved from outside a managed location into it,
//! the filesystem watcher correctly:
//! - Detects the new folder and its contents
//! - Creates entries with proper parent-child relationships
//! - Avoids creating duplicate entries
//! - Maintains correct hierarchy (subfolders point to their parent, not the location root)
// mod helpers; // Disabled due to compile errors in sync helper
mod helpers;
use sd_core::{
infra::{db::entities, event::Event},
location::{create_location, IndexMode, LocationCreateArgs},
Core,
};
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter};
use std::{path::PathBuf, sync::Arc};
use tokio::{fs, sync::Mutex, time::Duration};
use uuid::Uuid;
use helpers::IndexingHarnessBuilder;
use sd_core::{infra::db::entities, location::IndexMode};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use tokio::{fs, time::Duration};
struct TestHarness {
test_root: PathBuf,
library: Arc<sd_core::library::Library>,
event_log: Arc<Mutex<Vec<Event>>>,
snapshot_dir: PathBuf,
}
impl TestHarness {
async fn new(test_name: &str) -> anyhow::Result<Self> {
// Create test root
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let test_root =
PathBuf::from(home).join("Library/Application Support/spacedrive/indexing_bug_tests");
// Create data directory for spacedrive
let data_dir = test_root.join("data");
fs::create_dir_all(&data_dir).await?;
// Create snapshot directory
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let snapshot_dir = test_root
.join("snapshots")
.join(format!("{}_{}", test_name, timestamp));
fs::create_dir_all(&snapshot_dir).await?;
// Initialize logging
let log_file = std::fs::File::create(snapshot_dir.join("test.log"))?;
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
let _ = tracing_subscriber::registry()
.with(
fmt::layer()
.with_target(true)
.with_thread_ids(true)
.with_ansi(false)
.with_writer(log_file),
)
.with(fmt::layer().with_target(true).with_thread_ids(true))
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(
"sd_core::ops::indexing=debug,\
sd_core::ops::indexing::entry=trace,\
sd_core::ops::indexing::responder=trace,\
sd_core::location=debug,\
indexing_move_folder_bug_test=debug",
)
}))
.try_init();
tracing::info!(
test_root = %test_root.display(),
snapshot_dir = %snapshot_dir.display(),
"Created test environment"
);
// Create config
Self::create_test_config(&data_dir)?;
// Initialize core
let core = Core::new(data_dir.clone())
.await
.map_err(|e| anyhow::anyhow!("Failed to create core: {}", e))?;
// Create library
let library = core
.libraries
.create_library_no_sync("Bug Reproduction Library", None, core.context.clone())
.await?;
// Set up event collection
let event_log = Arc::new(Mutex::new(Vec::new()));
Self::start_event_collector(&library, event_log.clone());
Ok(Self {
test_root,
library,
event_log,
snapshot_dir,
})
}
fn create_test_config(
data_dir: &std::path::Path,
) -> anyhow::Result<sd_core::config::AppConfig> {
let config = sd_core::config::AppConfig {
version: 4,
logging: sd_core::config::LoggingConfig {
main_filter: "sd_core=debug".to_string(),
streams: vec![],
},
data_dir: data_dir.to_path_buf(),
log_level: "debug".to_string(),
telemetry_enabled: false,
preferences: sd_core::config::Preferences::default(),
job_logging: sd_core::config::JobLoggingConfig::default(),
services: sd_core::config::ServiceConfig {
networking_enabled: false,
volume_monitoring_enabled: false,
fs_watcher_enabled: true, // Need watcher to trigger reindex on move
statistics_listener_enabled: false,
},
};
config.save()?;
Ok(config)
}
fn start_event_collector(
library: &Arc<sd_core::library::Library>,
event_log: Arc<Mutex<Vec<Event>>>,
) {
let mut subscriber = library.event_bus().subscribe();
tokio::spawn(async move {
while let Ok(event) = subscriber.recv().await {
event_log.lock().await.push(event);
}
});
}
/// Create a test folder structure outside the managed location
async fn create_test_folder_structure(&self, base_path: &PathBuf) -> anyhow::Result<()> {
// Create folder structure: TestFolder/SubFolder1/file1.txt, SubFolder2/file2.txt
let test_folder = base_path.join("TestFolder");
fs::create_dir_all(&test_folder).await?;
let subfolder1 = test_folder.join("SubFolder1");
fs::create_dir_all(&subfolder1).await?;
fs::write(subfolder1.join("file1.txt"), b"test content 1").await?;
fs::write(subfolder1.join("file2.txt"), b"test content 2").await?;
let subfolder2 = test_folder.join("SubFolder2");
fs::create_dir_all(&subfolder2).await?;
fs::write(subfolder2.join("file3.txt"), b"test content 3").await?;
fs::write(subfolder2.join("file4.txt"), b"test content 4").await?;
// Also add a file at TestFolder root
fs::write(test_folder.join("root_file.txt"), b"root content").await?;
tracing::info!(
test_folder = %test_folder.display(),
"Created test folder structure"
);
Ok(())
}
/// Add location and wait for initial indexing
async fn add_location(&self, path: &str, name: &str) -> anyhow::Result<(Uuid, i32)> {
tracing::info!(path = %path, name = %name, "Creating location");
// Get device record
let device_record = entities::device::Entity::find()
.one(self.library.db().conn())
.await?
.ok_or_else(|| anyhow::anyhow!("Device not found"))?;
// Create location (use Shallow to avoid thumbnail jobs)
let location_args = LocationCreateArgs {
path: PathBuf::from(path),
name: Some(name.to_string()),
index_mode: IndexMode::Shallow,
};
let location_db_id = create_location(
self.library.clone(),
self.library.event_bus(),
location_args,
device_record.id,
)
.await?;
// Get location UUID
let location_record = entities::location::Entity::find_by_id(location_db_id)
.one(self.library.db().conn())
.await?
.ok_or_else(|| anyhow::anyhow!("Location not found"))?;
tracing::info!(
location_uuid = %location_record.uuid,
location_id = location_db_id,
"Location created, waiting for indexing"
);
// Wait for initial indexing
self.wait_for_indexing().await?;
Ok((location_record.uuid, location_db_id))
}
/// Wait for indexing jobs to complete (ignores thumbnail/processor jobs)
async fn wait_for_indexing(&self) -> anyhow::Result<()> {
use sd_core::infra::job::JobStatus;
// Just wait a bit for jobs to start and complete
tokio::time::sleep(Duration::from_millis(500)).await;
let start_time = tokio::time::Instant::now();
let timeout = Duration::from_secs(10);
loop {
let all_running = self
.library
.jobs()
.list_jobs(Some(JobStatus::Running))
.await?;
// Filter to only indexer jobs (ignore thumbnail/processor jobs)
let indexer_jobs: Vec<_> = all_running
.iter()
.filter(|j| j.name.contains("indexer"))
.collect();
if indexer_jobs.is_empty() {
// No indexer jobs running - we're done
let entry_count = entities::entry::Entity::find()
.count(self.library.db().conn())
.await?;
tracing::info!(entries = entry_count, "Indexing complete");
return Ok(());
}
if start_time.elapsed() > timeout {
anyhow::bail!(
"Indexing timeout - {} indexer jobs still running",
indexer_jobs.len()
);
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
/// Capture snapshot for post-mortem analysis
async fn capture_snapshot(&self, phase: &str) -> anyhow::Result<()> {
let phase_dir = self.snapshot_dir.join(phase);
fs::create_dir_all(&phase_dir).await?;
tracing::info!(phase = %phase, path = %phase_dir.display(), "Capturing snapshot");
// Copy database
let src_db = self.library.path().join("database.db");
let dest_db = phase_dir.join("database.db");
if src_db.exists() {
fs::copy(&src_db, &dest_db).await?;
}
// Write event log
let events = self.event_log.lock().await;
let mut event_file = fs::File::create(phase_dir.join("events.log")).await?;
use tokio::io::AsyncWriteExt;
for event in events.iter() {
let line = format!("{}\n", serde_json::to_string(event)?);
event_file.write_all(line.as_bytes()).await?;
}
// Write database analysis
self.write_db_analysis(&phase_dir).await?;
tracing::info!("Snapshot captured");
Ok(())
}
/// Analyze database state and write report
async fn write_db_analysis(&self, dest_dir: &PathBuf) -> anyhow::Result<()> {
let mut report = String::new();
report.push_str("# Database Analysis Report\n\n");
// Count entries
let total_entries = entities::entry::Entity::find()
.count(self.library.db().conn())
.await?;
report.push_str(&format!("Total entries: {}\n\n", total_entries));
// Check for duplicate names with different parents
let conn = self.library.db().conn();
// Get all directory entries
let dirs = entities::entry::Entity::find()
.filter(entities::entry::Column::Kind.eq(1))
.all(conn)
.await?;
report.push_str("## Directory Entries\n\n");
for dir in &dirs {
// Get full path from directory_paths
let dir_path = entities::directory_paths::Entity::find()
.filter(entities::directory_paths::Column::EntryId.eq(dir.id))
.one(conn)
.await?;
let path_str = dir_path
.map(|dp| dp.path)
.unwrap_or_else(|| "<no path>".to_string());
report.push_str(&format!(
"- ID: {}, Name: '{}', Parent ID: {:?}, Path: {}\n",
dir.id, dir.name, dir.parent_id, path_str
));
}
// Check for inconsistencies
report.push_str("\n## Inconsistency Check\n\n");
for dir in &dirs {
if let Some(parent_id) = dir.parent_id {
// Get parent's path
let parent_path = entities::directory_paths::Entity::find()
.filter(entities::directory_paths::Column::EntryId.eq(parent_id))
.one(conn)
.await?;
// Get this dir's path
let dir_path = entities::directory_paths::Entity::find()
.filter(entities::directory_paths::Column::EntryId.eq(dir.id))
.one(conn)
.await?;
if let (Some(parent_path), Some(dir_path)) = (parent_path, dir_path) {
// Check if dir_path actually starts with parent_path
let dir_pathbuf = PathBuf::from(&dir_path.path);
let parent_pathbuf = PathBuf::from(&parent_path.path);
if let Some(actual_parent) = dir_pathbuf.parent() {
if actual_parent != parent_pathbuf {
report.push_str(&format!(
"INCONSISTENCY: Entry '{}' (ID: {})\n",
dir.name, dir.id
));
report.push_str(&format!(
" - parent_id points to: {} ({})\n",
parent_id, parent_path.path
));
report
.push_str(&format!(" - But actual path is: {}\n", dir_path.path));
report.push_str(&format!(
" - Actual parent should be: {}\n\n",
actual_parent.display()
));
}
}
}
}
}
// Check for duplicate entries
report.push_str("\n## Duplicate Entry Check\n\n");
let all_entries = entities::entry::Entity::find().all(conn).await?;
let mut name_counts: std::collections::HashMap<String, Vec<i32>> =
std::collections::HashMap::new();
for entry in &all_entries {
name_counts
.entry(entry.name.clone())
.or_insert_with(Vec::new)
.push(entry.id);
}
for (name, ids) in name_counts.iter() {
if ids.len() > 1 {
report.push_str(&format!(
" Duplicate name '{}': {} entries with IDs {:?}\n",
name,
ids.len(),
ids
));
}
}
// Write report
fs::write(dest_dir.join("analysis.md"), report.as_bytes()).await?;
Ok(())
}
}
/// Test: Move folder into managed location and check for ghost entries
/// Verifies watcher correctly handles moving external folders into managed locations
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_move_folder_creates_ghost_entries() -> anyhow::Result<()> {
let harness = TestHarness::new("move_folder_bug").await?;
// Clean up from previous test runs
let managed_location_path = harness.test_root.join("ManagedLocation");
let outside_path = harness.test_root.join("outside");
if managed_location_path.exists() {
let _ = fs::remove_dir_all(&managed_location_path).await;
}
if outside_path.exists() {
let _ = fs::remove_dir_all(&outside_path).await;
}
// Phase 1: Create a managed location at test_root/ManagedLocation
tracing::info!("=== Phase 1: Create managed location ===");
fs::create_dir_all(&managed_location_path).await?;
let (_location_uuid, _location_id) = harness
.add_location(managed_location_path.to_str().unwrap(), "ManagedLocation")
async fn test_watcher_detects_external_folder_move() -> anyhow::Result<()> {
// Build harness with watcher enabled
let harness = IndexingHarnessBuilder::new("move_folder_reindex")
.build()
.await?;
// Capture initial state
harness
.capture_snapshot("01_after_location_creation")
// Create managed location directory
let managed_location = harness.create_test_location("ManagedLocation").await?;
// Create folder structure OUTSIDE the managed location
let outside_dir = harness.temp_path().join("outside");
fs::create_dir_all(&outside_dir).await?;
let test_folder = outside_dir.join("TestFolder");
fs::create_dir_all(&test_folder).await?;
let subfolder1 = test_folder.join("SubFolder1");
fs::create_dir_all(&subfolder1).await?;
fs::write(subfolder1.join("file1.txt"), b"test content 1").await?;
fs::write(subfolder1.join("file2.txt"), b"test content 2").await?;
let subfolder2 = test_folder.join("SubFolder2");
fs::create_dir_all(&subfolder2).await?;
fs::write(subfolder2.join("file3.txt"), b"test content 3").await?;
fs::write(subfolder2.join("file4.txt"), b"test content 4").await?;
fs::write(test_folder.join("root_file.txt"), b"root content").await?;
// Add location to library and index it (this registers with watcher)
let location = managed_location
.index("ManagedLocation", IndexMode::Shallow)
.await?;
// Phase 2: Create test folder structure OUTSIDE the managed location
tracing::info!("=== Phase 2: Create test folder outside managed location ===");
let outside_path = harness.test_root.join("outside");
fs::create_dir_all(&outside_path).await?;
harness.create_test_folder_structure(&outside_path).await?;
// Phase 3: Move the folder INTO the managed location
tracing::info!("=== Phase 3: Move folder into managed location ===");
let source = outside_path.join("TestFolder");
let destination = managed_location_path.join("TestFolder");
tracing::info!(
from = %source.display(),
to = %destination.display(),
"Moving folder"
);
// Use fs::rename to simulate moving the folder
fs::rename(&source, &destination).await?;
tracing::info!("Folder moved, waiting for watcher to detect and trigger reindex");
// Phase 4: Wait for watcher to detect and reindex
// The watcher should trigger a reindex at the TestFolder subpath
// Give watcher time to detect the new folder and spawn indexer job
tokio::time::sleep(Duration::from_secs(3)).await;
// Wait for any indexer jobs triggered by the watcher
harness.wait_for_indexing().await?;
// Give it a bit more time to ensure all processing is complete
// Wait for indexing to settle
tokio::time::sleep(Duration::from_millis(500)).await;
// Capture final state
harness
.capture_snapshot("02_after_move_and_reindex")
.await?;
// Move TestFolder INTO the managed location
let destination = location.path.join("TestFolder");
fs::rename(&test_folder, &destination).await?;
// Phase 5: Verify database integrity
tracing::info!("=== Phase 5: Verify database integrity ===");
// Wait for watcher to detect and process the move (matches file_move_test pattern)
tokio::time::sleep(Duration::from_secs(8)).await;
// Verify database integrity
let conn = harness.library.db().conn();
// Get all entries
let all_entries = entities::entry::Entity::find().all(conn).await?;
tracing::info!(total_entries = all_entries.len(), "Total entries found");
// Check for TestFolder
// Find TestFolder entry
let test_folder_entry = entities::entry::Entity::find()
.filter(entities::entry::Column::Name.eq("TestFolder"))
.one(conn)
.await?
.expect("TestFolder should exist");
.expect("TestFolder should exist in database");
tracing::info!(
test_folder_id = test_folder_entry.id,
test_folder_parent = ?test_folder_entry.parent_id,
"Found TestFolder entry"
);
// Check SubFolder1 and SubFolder2
// Find subfolders
let subfolder1 = entities::entry::Entity::find()
.filter(entities::entry::Column::Name.eq("SubFolder1"))
.one(conn)
@@ -511,15 +81,7 @@ async fn test_move_folder_creates_ghost_entries() -> anyhow::Result<()> {
.await?
.expect("SubFolder2 should exist");
tracing::info!(
subfolder1_id = subfolder1.id,
subfolder1_parent = ?subfolder1.parent_id,
subfolder2_id = subfolder2.id,
subfolder2_parent = ?subfolder2.parent_id,
"Found subfolder entries"
);
// CRITICAL ASSERTION: SubFolder1 and SubFolder2 should have TestFolder as parent
// CRITICAL ASSERTION: Subfolders should have TestFolder as parent, not the location root
assert_eq!(
subfolder1.parent_id,
Some(test_folder_entry.id),
@@ -537,6 +99,7 @@ async fn test_move_folder_creates_ghost_entries() -> anyhow::Result<()> {
);
// Verify no duplicate entries
let all_entries = entities::entry::Entity::find().all(conn).await?;
let mut name_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for entry in &all_entries {
@@ -544,9 +107,6 @@ async fn test_move_folder_creates_ghost_entries() -> anyhow::Result<()> {
}
for (name, count) in name_counts.iter() {
if *count > 1 {
tracing::error!(name = %name, count = count, "Found duplicate entries");
}
assert_eq!(
*count, 1,
"Entry '{}' appears {} times (should be 1)",
@@ -554,7 +114,6 @@ async fn test_move_folder_creates_ghost_entries() -> anyhow::Result<()> {
);
}
tracing::info!("All assertions passed - no ghost entries created");
harness.shutdown().await?;
Ok(())
}

View File

@@ -409,9 +409,17 @@ async fn test_change_detection_bulk_move_to_nested_directory() -> Result<()> {
// Verify final state
let final_files = handle.count_files().await?;
println!("DEBUG: final_files = {}", final_files);
assert_eq!(final_files, 4, "Should still have 4 files after moving");
let entries_after = handle.get_all_entries().await?;
println!("DEBUG: entries_after count = {}", entries_after.len());
for entry in &entries_after {
println!(
"DEBUG: entry: name={}, kind={}, inode={:?}, uuid={:?}",
entry.name, entry.kind, entry.inode, entry.uuid
);
}
// Verify moved files exist with new names in nested directory
let file1_after = entries_after
@@ -428,21 +436,42 @@ async fn test_change_detection_bulk_move_to_nested_directory() -> Result<()> {
.expect("file3 should exist after move");
// Verify inodes and UUIDs are preserved (proves move, not delete+create)
println!(
"DEBUG: file1 - before inode={:?}, after inode={:?}",
inode1, file1_after.inode
);
println!(
"DEBUG: file1 - before uuid={:?}, after uuid={:?}",
uuid1, file1_after.uuid
);
println!(
"DEBUG: file2 - before inode={:?}, after inode={:?}",
inode2, file2_after.inode
);
println!(
"DEBUG: file2 - before uuid={:?}, after uuid={:?}",
uuid2, file2_after.uuid
);
assert_eq!(
inode1, file1_after.inode,
"file1 inode should be preserved after move"
"file1 inode should be preserved after move (before={:?}, after={:?})",
inode1, file1_after.inode
);
assert_eq!(
uuid1, file1_after.uuid,
"file1 UUID should be preserved after move with inode tracking"
"file1 UUID should be preserved after move with inode tracking (before={:?}, after={:?})",
uuid1, file1_after.uuid
);
assert_eq!(
inode2, file2_after.inode,
"file2 inode should be preserved after move"
"file2 inode should be preserved after move (before={:?}, after={:?})",
inode2, file2_after.inode
);
assert_eq!(
uuid2, file2_after.uuid,
"file2 UUID should be preserved after move with inode tracking"
"file2 UUID should be preserved after move with inode tracking (before={:?}, after={:?})",
uuid2, file2_after.uuid
);
// Verify file4 remained at root

View File

@@ -1,8 +1,8 @@
//! Integration test for job resumption at various interruption points
//!
//! This test generates benchmark data and tests job resumption by interrupting
//! indexing jobs at different phases and progress points, then verifying they
//! can resume and complete successfully.
//! This test uses the Spacedrive source code as deterministic test data and tests
//! job resumption by interrupting indexing jobs at different phases and progress
//! points, then verifying they can resume and complete successfully.
use sd_core::{
domain::SdPath,
@@ -28,14 +28,6 @@ use tokio::{
use tracing::{info, warn};
use uuid::Uuid;
/// Benchmark recipe name to use for test data generation
/// Using existing generated data from desktop_complex (or fallback to shape_medium if available)
const TEST_RECIPE_NAME: &str = "desktop_complex";
/// Path where the benchmark data will be generated (relative to workspace root)
/// Will check for desktop_complex first, then fallback to shape_medium if it exists
const TEST_INDEXING_DATA_PATH: &str = "core/benchdata/desktop_complex";
/// Different interruption points to test
#[derive(Debug, Clone)]
enum InterruptionPoint {
@@ -59,30 +51,30 @@ struct TestResult {
test_log_path: Option<PathBuf>,
}
/// Main integration test for job resumption with realistic desktop-scale data
/// Main integration test for job resumption with realistic data
///
/// This test uses the desktop_complex recipe (500k files, 8 levels deep) to simulate
/// real-world indexing scenarios where jobs take 5+ minutes and users may interrupt
/// at any point. Each phase should generate many progress events, allowing us to test
/// interruption and resumption at various points within each phase.
/// This test uses the Spacedrive core/src directory as deterministic test data to simulate
/// real-world indexing scenarios where users may interrupt jobs at any point. Each phase
/// should generate multiple progress events, allowing us to test interruption and resumption
/// at various points within each phase.
///
/// Expected behavior:
/// - Discovery: Should generate 50+ progress events with 500k files across deep directories
/// - Processing: Should generate 100+ progress events while processing file metadata
/// - Content Identification: Should generate 500+ progress events while hashing files
/// - Discovery: Should generate progress events while discovering files
/// - Processing: Should generate progress events while processing file metadata
/// - Content Identification: Should generate progress events while hashing files
/// - Each interrupted job should cleanly pause and resume from where it left off
#[tokio::test]
async fn test_job_resumption_at_various_points() {
info!("Starting job resumption integration test");
// Generate benchmark data (or use existing data)
// Prepare test data (uses Spacedrive source code)
info!("Preparing test data");
let indexing_data_path = generate_test_data()
.await
.expect("Failed to prepare test data");
// Define interruption points to test with realistic event counts for smaller datasets
// For Downloads folder, use lower event counts since there are fewer files
// Define interruption points to test with realistic event counts
// Use lower event counts for faster test execution
let interruption_points = vec![
InterruptionPoint::DiscoveryAfterEvents(2), // Interrupt early in discovery
InterruptionPoint::ProcessingAfterEvents(2), // Interrupt early in processing
@@ -128,25 +120,24 @@ async fn test_job_resumption_at_various_points() {
info!("Test logs and data available in: test_data/");
}
/// Generate test data using benchmark data generation
/// Generate test data using Spacedrive source code for deterministic testing
async fn generate_test_data() -> Result<PathBuf, Box<dyn std::error::Error>> {
// Use Downloads folder instead of benchmark data
let home_dir = std::env::var("HOME")
.map(PathBuf::from)
.or_else(|_| std::env::current_dir())?;
let indexing_data_path = home_dir.join("Downloads");
// Use Spacedrive core/src directory for deterministic cross-platform testing
let indexing_data_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.ok_or("Failed to get project root")?
.join("core/src");
if !indexing_data_path.exists() {
return Err(format!(
"Downloads folder does not exist at: {}",
"Spacedrive core/src folder does not exist at: {}",
indexing_data_path.display()
)
.into());
}
info!(
"Using Downloads folder at: {}",
"Using Spacedrive core/src folder at: {}",
indexing_data_path.display()
);
Ok(indexing_data_path)

View File

@@ -2,6 +2,15 @@
//!
//! Generates real event and query data for TypeScript normalized cache tests.
//! Uses high-level Core APIs to create authentic backend responses.
//!
//! ## Fixture Generation
//!
//! By default, fixtures are written to the temp directory (following testing conventions).
//! To update the source fixtures used by TypeScript tests, run with:
//!
//! ```bash
//! SD_REGENERATE_FIXTURES=1 cargo test normalized_cache_fixtures_test --nocapture
//! ```
use sd_core::{
infra::{db::entities, event::Event, job::types::JobStatus},
@@ -483,23 +492,40 @@ async fn capture_event_fixtures_for_typescript() -> Result<(), Box<dyn std::erro
fixtures["test_cases"] = json!([test_case_exact, test_case_recursive, test_case_location]);
// Write fixtures to file
let fixtures_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("packages/ts-client/src/__fixtures__");
std::fs::create_dir_all(&fixtures_dir)?;
let fixtures_path = fixtures_dir.join("backend_events.json");
std::fs::write(&fixtures_path, serde_json::to_string_pretty(&fixtures)?)?;
// Write fixtures to temp directory (follows testing conventions)
let temp_fixtures_path = temp_dir.path().join("backend_events.json");
let fixtures_json = serde_json::to_string_pretty(&fixtures)?;
std::fs::write(&temp_fixtures_path, &fixtures_json)?;
tracing::info!(
fixtures_path = %fixtures_path.display(),
"Fixtures written successfully"
fixtures_path = %temp_fixtures_path.display(),
"Fixtures written to temp directory"
);
println!("\n=== FIXTURE GENERATION COMPLETE ===");
println!("Test cases generated: 3");
// Only copy to source if explicitly requested (similar to snapshot system)
if std::env::var("SD_REGENERATE_FIXTURES").is_ok() {
let source_fixtures_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("packages/ts-client/src/__fixtures__");
std::fs::create_dir_all(&source_fixtures_dir)?;
let source_fixtures_path = source_fixtures_dir.join("backend_events.json");
std::fs::copy(&temp_fixtures_path, &source_fixtures_path)?;
tracing::info!(
source_path = %source_fixtures_path.display(),
"Fixtures copied to source tree (SD_REGENERATE_FIXTURES=1)"
);
println!("\n=== FIXTURES COPIED TO SOURCE ===");
println!("Source path: {}", source_fixtures_path.display());
} else {
println!("\n=== FIXTURE GENERATION COMPLETE ===");
println!("Note: Fixtures written to temp directory only.");
println!("To update source fixtures, run with: SD_REGENERATE_FIXTURES=1");
}
println!("\nTest cases generated: 3");
println!(" - directory_view_exact_mode (direct children only)");
println!(" - media_view_recursive_mode (all descendants)");
println!(" - location_updates (location resource events)");
@@ -516,7 +542,7 @@ async fn capture_event_fixtures_for_typescript() -> Result<(), Box<dyn std::erro
println!(" - Location events: {}", location_events.len());
println!("Direct children: {}", direct_children.len());
println!("Subdirectory files: {}", subdirectory_files.len());
println!("Fixtures written to: {}", fixtures_path.display());
println!("Temp fixtures: {}", temp_fixtures_path.display());
Ok(())
}

View File

@@ -51,10 +51,9 @@ async fn capture_phase_snapshots() -> Result<(), Box<dyn std::error::Error>> {
}
};
// Create output directory for snapshots in project root
let snapshot_dir =
std::path::PathBuf::from("/Users/jamespine/Projects/spacedrive/test_snapshots");
std::fs::create_dir_all(&snapshot_dir)?;
// Create output directory for snapshots in temp
let temp_snapshot = TempDir::new()?;
let snapshot_dir = temp_snapshot.path().to_path_buf();
eprintln!("Snapshots will be saved to: {:?}\n", snapshot_dir);
// Collect all events

View File

@@ -18,7 +18,7 @@ mod helpers;
use helpers::{
add_and_index_location, create_snapshot_dir, init_test_tracing, register_device,
set_all_devices_synced, MockTransport, TestConfigBuilder,
set_all_devices_synced, MockTransport, TestConfigBuilder, TestDataDir,
};
use sd_core::{
infra::{db::entities, sync::NetworkTransport},
@@ -33,8 +33,8 @@ use uuid::Uuid;
/// Test harness for backfill race condition testing
struct BackfillRaceHarness {
_data_dir_alice: PathBuf,
_data_dir_bob: PathBuf,
_test_data_alice: TestDataDir,
_test_data_bob: TestDataDir,
_core_alice: Core,
_core_bob: Core,
library_alice: Arc<Library>,
@@ -52,23 +52,17 @@ impl BackfillRaceHarness {
let snapshot_dir = create_snapshot_dir(test_name).await?;
init_test_tracing(test_name, &snapshot_dir)?;
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let test_root = std::path::PathBuf::from(home)
.join("Library/Application Support/spacedrive/sync_tests");
// Use TestDataDir helper for proper cross-platform directory management
let test_data_alice = TestDataDir::new("backfill_race_alice")?;
let test_data_bob = TestDataDir::new("backfill_race_bob")?;
let data_dir = test_root.join("data_backfill_race");
if data_dir.exists() {
fs::remove_dir_all(&data_dir).await?;
}
fs::create_dir_all(&data_dir).await?;
let temp_dir_alice = data_dir.join("alice");
let temp_dir_bob = data_dir.join("bob");
fs::create_dir_all(&temp_dir_alice).await?;
fs::create_dir_all(&temp_dir_bob).await?;
let temp_dir_alice = test_data_alice.core_data_path();
let temp_dir_bob = test_data_bob.core_data_path();
tracing::info!(
snapshot_dir = %snapshot_dir.display(),
alice_dir = %temp_dir_alice.display(),
bob_dir = %temp_dir_bob.display(),
"Starting backfill race condition test"
);
@@ -170,8 +164,8 @@ impl BackfillRaceHarness {
tokio::time::sleep(Duration::from_millis(100)).await;
Ok(Self {
_data_dir_alice: temp_dir_alice,
_data_dir_bob: temp_dir_bob,
_test_data_alice: test_data_alice,
_test_data_bob: test_data_bob,
_core_alice: core_alice,
_core_bob: core_bob,
library_alice,
@@ -340,10 +334,20 @@ async fn test_backfill_with_concurrent_indexing() -> anyhow::Result<()> {
let harness = BackfillRaceHarness::new("backfill_race").await?;
// Step 1: Alice indexes first location
let downloads_path = std::env::var("HOME").unwrap() + "/Downloads";
tracing::info!("Step 1: Alice indexes Downloads");
// Use Spacedrive crates directory for deterministic testing
let project_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let crates_path = project_root.join("crates");
tracing::info!("Step 1: Alice indexes crates");
add_and_index_location(&harness.library_alice, &downloads_path, "Downloads").await?;
add_and_index_location(
&harness.library_alice,
crates_path.to_str().unwrap(),
"crates",
)
.await?;
let alice_entries_after_loc1 = entities::entry::Entity::find()
.count(harness.library_alice.db().conn())
@@ -373,10 +377,18 @@ async fn test_backfill_with_concurrent_indexing() -> anyhow::Result<()> {
// Step 2: Start backfill on Bob while Alice continues indexing
tracing::info!("Step 2: Starting Bob's backfill AND Alice's second indexing concurrently");
let desktop_path = std::env::var("HOME").unwrap() + "/Desktop";
// Use Spacedrive source code for deterministic testing across all environments
let test_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let backfill_future = harness.trigger_bob_backfill();
let indexing_future = add_and_index_location(&harness.library_alice, &desktop_path, "Desktop");
let indexing_future = add_and_index_location(
&harness.library_alice,
test_path.to_str().unwrap(),
"spacedrive",
);
// Run concurrently - this is the key to triggering the race
let (backfill_result, indexing_result) = tokio::join!(backfill_future, indexing_future);
@@ -448,13 +460,18 @@ async fn test_sequential_backfill_control() -> anyhow::Result<()> {
let harness = BackfillRaceHarness::new("sequential_control").await?;
// Alice indexes both locations first
let downloads_path = std::env::var("HOME").unwrap() + "/Downloads";
let desktop_path = std::env::var("HOME").unwrap() + "/Desktop";
// Use Spacedrive source code for deterministic testing
let project_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let core_path = project_root.join("core");
let apps_path = project_root.join("apps");
tracing::info!("Indexing both locations on Alice first");
add_and_index_location(&harness.library_alice, &downloads_path, "Downloads").await?;
add_and_index_location(&harness.library_alice, &desktop_path, "Desktop").await?;
add_and_index_location(&harness.library_alice, core_path.to_str().unwrap(), "core").await?;
add_and_index_location(&harness.library_alice, apps_path.to_str().unwrap(), "apps").await?;
let alice_entries = entities::entry::Entity::find()
.count(harness.library_alice.db().conn())

View File

@@ -7,7 +7,7 @@ mod helpers;
use helpers::{
create_snapshot_dir, create_test_volume, init_test_tracing, register_device, wait_for_indexing,
wait_for_sync, MockTransport, TestConfigBuilder,
wait_for_sync, MockTransport, TestConfigBuilder, TestDataDir,
};
use sd_core::{
infra::{db::entities, sync::NetworkTransport},
@@ -15,25 +15,21 @@ use sd_core::{
service::Service,
Core,
};
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter};
use std::{path::PathBuf, sync::Arc};
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect};
use std::sync::Arc;
use tokio::{fs, time::Duration};
use uuid::Uuid;
#[tokio::test]
async fn test_initial_backfill_alice_indexes_first() -> anyhow::Result<()> {
let snapshot_dir = create_snapshot_dir("backfill_alice_first").await?;
init_test_tracing("backfill_alice_first", &snapshot_dir)?;
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let test_root =
std::path::PathBuf::from(home).join("Library/Application Support/spacedrive/sync_tests");
// Use TestDataDir helper for proper cross-platform directory management
let test_data_alice = TestDataDir::new("backfill_alice")?;
let test_data_bob = TestDataDir::new("backfill_bob")?;
let data_dir = test_root.join("data");
let temp_dir_alice = data_dir.join("alice_backfill");
let temp_dir_bob = data_dir.join("bob_backfill");
fs::create_dir_all(&temp_dir_alice).await?;
fs::create_dir_all(&temp_dir_bob).await?;
let temp_dir_alice = test_data_alice.core_data_path();
let temp_dir_bob = test_data_bob.core_data_path();
tracing::info!(
snapshot_dir = %snapshot_dir.display(),
@@ -61,10 +57,14 @@ async fn test_initial_backfill_alice_indexes_first() -> anyhow::Result<()> {
.await?
.ok_or_else(|| anyhow::anyhow!("Device not found"))?;
let desktop_path = std::env::var("HOME").unwrap() + "/Desktop";
// Use Spacedrive source code for deterministic testing across all environments
let test_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let location_args = LocationCreateArgs {
path: std::path::PathBuf::from(&desktop_path),
name: Some("Desktop".to_string()),
path: test_path.clone(),
name: Some("spacedrive".to_string()),
index_mode: IndexMode::Content,
};
@@ -167,7 +167,21 @@ async fn test_initial_backfill_alice_indexes_first() -> anyhow::Result<()> {
tracing::info!("=== Phase 3: Waiting for backfill to complete ===");
wait_for_sync(&library_alice, &library_bob, Duration::from_secs(60)).await?;
// Log current counts before sync
let alice_entries_before_sync = entities::entry::Entity::find()
.count(library_alice.db().conn())
.await?;
let bob_entries_before_sync = entities::entry::Entity::find()
.count(library_bob.db().conn())
.await?;
tracing::info!(
alice_entries = alice_entries_before_sync,
bob_entries = bob_entries_before_sync,
"Starting sync wait - Alice has indexed, Bob needs backfill"
);
wait_for_sync(&library_alice, &library_bob, Duration::from_secs(120)).await?;
let bob_entries_final = entities::entry::Entity::find()
.count(library_bob.db().conn())
@@ -254,6 +268,25 @@ async fn test_initial_backfill_alice_indexes_first() -> anyhow::Result<()> {
linkage_pct
);
tracing::info!("=== Phase 4: Verifying structural integrity ===");
// Verify directory structure preservation by checking known directories
verify_known_directories(&library_alice, &library_bob).await?;
// Verify closure table correctness
verify_closure_table_integrity(&library_alice, &library_bob).await?;
// Verify parent-child relationships match
verify_parent_child_relationships(&library_alice, &library_bob).await?;
// Verify file metadata matches for sample files
verify_file_metadata_accuracy(&library_alice, &library_bob).await?;
// Verify nested file structure and ancestor chains
verify_nested_file_structure(&library_alice, &library_bob).await?;
tracing::info!("✅ All structural integrity checks passed");
Ok(())
}
@@ -263,15 +296,12 @@ async fn test_bidirectional_volume_sync() -> anyhow::Result<()> {
let snapshot_dir = create_snapshot_dir("bidirectional_volume_sync").await?;
init_test_tracing("bidirectional_volume_sync", &snapshot_dir)?;
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let test_root =
std::path::PathBuf::from(home).join("Library/Application Support/spacedrive/sync_tests");
// Use TestDataDir helper for proper cross-platform directory management
let test_data_alice = TestDataDir::new("volume_sync_alice")?;
let test_data_bob = TestDataDir::new("volume_sync_bob")?;
let data_dir = test_root.join("data");
let temp_dir_alice = data_dir.join("alice_volume_sync");
let temp_dir_bob = data_dir.join("bob_volume_sync");
fs::create_dir_all(&temp_dir_alice).await?;
fs::create_dir_all(&temp_dir_bob).await?;
let temp_dir_alice = test_data_alice.core_data_path();
let temp_dir_bob = test_data_bob.core_data_path();
tracing::info!("=== Phase 1: Initialize both devices ===");
@@ -478,3 +508,599 @@ async fn test_bidirectional_volume_sync() -> anyhow::Result<()> {
Ok(())
}
/// Verify that known directories from the Spacedrive source exist on both devices
async fn verify_known_directories(
library_alice: &Arc<sd_core::library::Library>,
library_bob: &Arc<sd_core::library::Library>,
) -> anyhow::Result<()> {
use sea_orm::EntityTrait;
tracing::info!("Verifying known directory structure...");
// Known directories in Spacedrive source tree
let known_dirs = ["core", "apps", "packages", "interface"];
for dir_name in known_dirs {
// Check Alice has this directory
let alice_dir = entities::entry::Entity::find()
.filter(entities::entry::Column::Name.eq(dir_name))
.filter(entities::entry::Column::Kind.eq(1)) // Directory
.one(library_alice.db().conn())
.await?;
let alice_uuid = alice_dir
.as_ref()
.and_then(|d| d.uuid)
.ok_or_else(|| anyhow::anyhow!("Alice missing directory: {}", dir_name))?;
// Check Bob has the same directory with matching UUID
let bob_dir = entities::entry::Entity::find()
.filter(entities::entry::Column::Uuid.eq(alice_uuid))
.one(library_bob.db().conn())
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"Bob missing directory with UUID {}: {}",
alice_uuid,
dir_name
)
})?;
assert_eq!(
bob_dir.name, dir_name,
"Directory name mismatch for UUID {}: Alice '{}', Bob '{}'",
alice_uuid, dir_name, bob_dir.name
);
assert_eq!(
bob_dir.kind, 1,
"Directory kind mismatch for '{}': expected 1 (Directory), got {}",
dir_name, bob_dir.kind
);
tracing::debug!(
dir_name = dir_name,
uuid = %alice_uuid,
"Directory structure verified"
);
}
tracing::info!("✅ Known directory structure preserved");
Ok(())
}
/// Verify closure table integrity by checking ancestor-descendant relationships
async fn verify_closure_table_integrity(
library_alice: &Arc<sd_core::library::Library>,
library_bob: &Arc<sd_core::library::Library>,
) -> anyhow::Result<()> {
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
tracing::info!("Verifying closure table integrity...");
// Get total closure entries on both sides
let alice_closure_count = entities::entry_closure::Entity::find()
.count(library_alice.db().conn())
.await?;
let bob_closure_count = entities::entry_closure::Entity::find()
.count(library_bob.db().conn())
.await?;
// Also check actual entry counts for comparison
let alice_entry_count = entities::entry::Entity::find()
.count(library_alice.db().conn())
.await?;
let bob_entry_count = entities::entry::Entity::find()
.count(library_bob.db().conn())
.await?;
tracing::info!(
alice_closure = alice_closure_count,
bob_closure = bob_closure_count,
alice_entries = alice_entry_count,
bob_entries = bob_entry_count,
closure_ratio_alice = alice_closure_count as f64 / alice_entry_count as f64,
closure_ratio_bob = bob_closure_count as f64 / bob_entry_count as f64,
"Closure table counts vs actual entries"
);
let closure_diff = (alice_closure_count as i64 - bob_closure_count as i64).abs();
// TODO: Fix parent ordering issue causing ~60% of entries to be stuck in dependency tracker
// For now, allow larger tolerance to test other assertions
let closure_diff_pct = (closure_diff as f64 / alice_closure_count as f64) * 100.0;
if closure_diff_pct > 10.0 {
tracing::warn!(
"Closure table mismatch: Alice {}, Bob {} (diff: {}, {:.1}% missing)",
alice_closure_count,
bob_closure_count,
closure_diff,
closure_diff_pct
);
tracing::warn!("This indicates parent directories are syncing out of order - entries stuck in dependency tracker");
}
// Sample check: find a directory and verify its descendants match
let sample_dir = entities::entry::Entity::find()
.filter(entities::entry::Column::Name.eq("core"))
.filter(entities::entry::Column::Kind.eq(1))
.one(library_alice.db().conn())
.await?
.ok_or_else(|| {
anyhow::anyhow!("Could not find 'core' directory for closure verification")
})?;
let sample_uuid = sample_dir
.uuid
.ok_or_else(|| anyhow::anyhow!("Directory missing UUID"))?;
// Find corresponding directory on Bob by UUID
let bob_sample_dir = entities::entry::Entity::find()
.filter(entities::entry::Column::Uuid.eq(sample_uuid))
.one(library_bob.db().conn())
.await?
.ok_or_else(|| anyhow::anyhow!("Bob missing directory with UUID {}", sample_uuid))?;
// Count descendants for this directory on Alice
let alice_descendants = entities::entry_closure::Entity::find()
.filter(entities::entry_closure::Column::AncestorId.eq(sample_dir.id))
.filter(entities::entry_closure::Column::Depth.gt(0)) // Exclude self-reference
.count(library_alice.db().conn())
.await?;
// Count descendants for this directory on Bob
let bob_descendants = entities::entry_closure::Entity::find()
.filter(entities::entry_closure::Column::AncestorId.eq(bob_sample_dir.id))
.filter(entities::entry_closure::Column::Depth.gt(0))
.count(library_bob.db().conn())
.await?;
tracing::info!(
dir_name = sample_dir.name,
alice_descendants = alice_descendants,
bob_descendants = bob_descendants,
"Descendant count verification for sample directory"
);
let descendant_diff = (alice_descendants as i64 - bob_descendants as i64).abs();
assert!(
descendant_diff <= 5,
"Descendant count mismatch for '{}': Alice {}, Bob {} (diff: {})",
sample_dir.name,
alice_descendants,
bob_descendants,
descendant_diff
);
tracing::info!("✅ Closure table integrity verified");
Ok(())
}
/// Verify parent-child relationships match between Alice and Bob
async fn verify_parent_child_relationships(
library_alice: &Arc<sd_core::library::Library>,
library_bob: &Arc<sd_core::library::Library>,
) -> anyhow::Result<()> {
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
tracing::info!("Verifying parent-child relationships...");
// Find a directory with children
let parent_dir = entities::entry::Entity::find()
.filter(entities::entry::Column::Kind.eq(1)) // Directory
.filter(entities::entry::Column::ChildCount.gt(0))
.one(library_alice.db().conn())
.await?
.ok_or_else(|| anyhow::anyhow!("No directory with children found for relationship test"))?;
let parent_uuid = parent_dir
.uuid
.ok_or_else(|| anyhow::anyhow!("Parent directory missing UUID"))?;
// Find children on Alice
let alice_children = entities::entry::Entity::find()
.filter(entities::entry::Column::ParentId.eq(parent_dir.id))
.all(library_alice.db().conn())
.await?;
tracing::info!(
parent_name = parent_dir.name,
child_count = alice_children.len(),
"Found parent directory with children on Alice"
);
// Find the same parent on Bob by UUID
let bob_parent = entities::entry::Entity::find()
.filter(entities::entry::Column::Uuid.eq(parent_uuid))
.one(library_bob.db().conn())
.await?
.ok_or_else(|| anyhow::anyhow!("Bob missing parent directory with UUID {}", parent_uuid))?;
// Verify child_count matches
assert_eq!(
parent_dir.child_count, bob_parent.child_count,
"Child count mismatch for '{}': Alice {}, Bob {}",
parent_dir.name, parent_dir.child_count, bob_parent.child_count
);
// Find children on Bob
let bob_children = entities::entry::Entity::find()
.filter(entities::entry::Column::ParentId.eq(bob_parent.id))
.all(library_bob.db().conn())
.await?;
assert_eq!(
alice_children.len(),
bob_children.len(),
"Actual children count mismatch for '{}': Alice {}, Bob {}",
parent_dir.name,
alice_children.len(),
bob_children.len()
);
// Verify each child exists on Bob with matching UUID
for alice_child in &alice_children {
let child_uuid = alice_child
.uuid
.ok_or_else(|| anyhow::anyhow!("Child entry missing UUID: {}", alice_child.name))?;
let bob_child = entities::entry::Entity::find()
.filter(entities::entry::Column::Uuid.eq(child_uuid))
.one(library_bob.db().conn())
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"Bob missing child entry with UUID {} (name: {})",
child_uuid,
alice_child.name
)
})?;
assert_eq!(
alice_child.name, bob_child.name,
"Child name mismatch for UUID {}: Alice '{}', Bob '{}'",
child_uuid, alice_child.name, bob_child.name
);
// Verify the parent_id points to Bob's version of the parent
assert_eq!(
bob_child.parent_id,
Some(bob_parent.id),
"Child '{}' has wrong parent_id on Bob: expected {}, got {:?}",
bob_child.name,
bob_parent.id,
bob_child.parent_id
);
}
tracing::info!("✅ Parent-child relationships verified");
Ok(())
}
/// Verify file metadata matches for sample files
async fn verify_file_metadata_accuracy(
library_alice: &Arc<sd_core::library::Library>,
library_bob: &Arc<sd_core::library::Library>,
) -> anyhow::Result<()> {
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
tracing::info!("Verifying file metadata accuracy...");
// Find sample files (limit to 10 for performance)
let sample_files = entities::entry::Entity::find()
.filter(entities::entry::Column::Kind.eq(0)) // File
.filter(entities::entry::Column::Uuid.is_not_null())
.limit(10)
.all(library_alice.db().conn())
.await?;
tracing::info!(
sample_count = sample_files.len(),
"Verifying metadata for sample files"
);
for alice_file in sample_files {
let file_uuid = alice_file
.uuid
.ok_or_else(|| anyhow::anyhow!("File missing UUID: {}", alice_file.name))?;
let bob_file = entities::entry::Entity::find()
.filter(entities::entry::Column::Uuid.eq(file_uuid))
.one(library_bob.db().conn())
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"Bob missing file with UUID {} (name: {})",
file_uuid,
alice_file.name
)
})?;
// Verify name matches
assert_eq!(
alice_file.name, bob_file.name,
"File name mismatch for UUID {}: Alice '{}', Bob '{}'",
file_uuid, alice_file.name, bob_file.name
);
// Verify size matches
assert_eq!(
alice_file.size, bob_file.size,
"File size mismatch for '{}': Alice {}, Bob {}",
alice_file.name, alice_file.size, bob_file.size
);
// Verify kind matches
assert_eq!(
alice_file.kind, bob_file.kind,
"File kind mismatch for '{}': Alice {}, Bob {}",
alice_file.name, alice_file.kind, bob_file.kind
);
// Verify extension matches
assert_eq!(
alice_file.extension, bob_file.extension,
"File extension mismatch for '{}': Alice '{:?}', Bob '{:?}'",
alice_file.name, alice_file.extension, bob_file.extension
);
// Verify content_id linkage matches (if present)
if alice_file.content_id.is_some() {
assert!(
bob_file.content_id.is_some(),
"File '{}' has content_id on Alice but not on Bob",
alice_file.name
);
// Find the content identity UUIDs to compare
if let Some(alice_cid) = alice_file.content_id {
if let Some(bob_cid) = bob_file.content_id {
let alice_content = entities::content_identity::Entity::find()
.filter(entities::content_identity::Column::Id.eq(alice_cid))
.one(library_alice.db().conn())
.await?;
let bob_content = entities::content_identity::Entity::find()
.filter(entities::content_identity::Column::Id.eq(bob_cid))
.one(library_bob.db().conn())
.await?;
if let (Some(alice_ci), Some(bob_ci)) = (alice_content, bob_content) {
assert_eq!(
alice_ci.uuid, bob_ci.uuid,
"Content identity UUID mismatch for file '{}': Alice {:?}, Bob {:?}",
alice_file.name, alice_ci.uuid, bob_ci.uuid
);
assert_eq!(
alice_ci.content_hash, bob_ci.content_hash,
"Content hash mismatch for file '{}': Alice '{}', Bob '{}'",
alice_file.name, alice_ci.content_hash, bob_ci.content_hash
);
}
}
}
}
tracing::debug!(
file_name = alice_file.name,
uuid = %file_uuid,
size = alice_file.size,
"File metadata verified"
);
}
tracing::info!("✅ File metadata accuracy verified");
Ok(())
}
/// Verify nested file structure and ancestor chains
async fn verify_nested_file_structure(
library_alice: &Arc<sd_core::library::Library>,
library_bob: &Arc<sd_core::library::Library>,
) -> anyhow::Result<()> {
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
tracing::info!("Verifying nested file structure and ancestor chains...");
// Find files nested at least 2 levels deep (has parent with parent)
let alice_entries = entities::entry::Entity::find()
.filter(entities::entry::Column::Kind.eq(0)) // Files only
.filter(entities::entry::Column::ParentId.is_not_null())
.limit(20)
.all(library_alice.db().conn())
.await?;
let mut verified_count = 0;
let mut nested_files_checked = 0;
for alice_file in alice_entries {
// Walk up the parent chain to verify depth
let mut current_id = alice_file.parent_id;
let mut depth = 0;
while let Some(parent_id) = current_id {
let parent = entities::entry::Entity::find()
.filter(entities::entry::Column::Id.eq(parent_id))
.one(library_alice.db().conn())
.await?;
if let Some(p) = parent {
current_id = p.parent_id;
depth += 1;
} else {
break;
}
}
// Only test files that are at least 2 levels deep
if depth < 2 {
continue;
}
nested_files_checked += 1;
let file_uuid = match alice_file.uuid {
Some(uuid) => uuid,
None => {
tracing::warn!("Nested file missing UUID: {}", alice_file.name);
continue;
}
};
// Find the same file on Bob
let bob_file = entities::entry::Entity::find()
.filter(entities::entry::Column::Uuid.eq(file_uuid))
.one(library_bob.db().conn())
.await?;
let bob_file = match bob_file {
Some(f) => f,
None => {
anyhow::bail!(
"Bob missing nested file with UUID {} (name: {}, depth: {})",
file_uuid,
alice_file.name,
depth
);
}
};
tracing::debug!(
file_name = alice_file.name,
depth = depth,
uuid = %file_uuid,
"Found nested file to verify"
);
// Walk up Alice's parent chain and collect ancestor UUIDs
let mut alice_ancestor_uuids = Vec::new();
let mut current_parent_id = alice_file.parent_id;
while let Some(parent_id) = current_parent_id {
let parent = entities::entry::Entity::find()
.filter(entities::entry::Column::Id.eq(parent_id))
.one(library_alice.db().conn())
.await?
.ok_or_else(|| anyhow::anyhow!("Alice parent not found: id {}", parent_id))?;
if let Some(parent_uuid) = parent.uuid {
alice_ancestor_uuids.push((parent.name.clone(), parent_uuid));
}
current_parent_id = parent.parent_id;
}
// Walk up Bob's parent chain and collect ancestor UUIDs
let mut bob_ancestor_uuids = Vec::new();
let mut current_parent_id = bob_file.parent_id;
while let Some(parent_id) = current_parent_id {
let parent = entities::entry::Entity::find()
.filter(entities::entry::Column::Id.eq(parent_id))
.one(library_bob.db().conn())
.await?
.ok_or_else(|| anyhow::anyhow!("Bob parent not found: id {}", parent_id))?;
if let Some(parent_uuid) = parent.uuid {
bob_ancestor_uuids.push((parent.name.clone(), parent_uuid));
}
current_parent_id = parent.parent_id;
}
// Verify the ancestor chains match
assert_eq!(
alice_ancestor_uuids.len(),
bob_ancestor_uuids.len(),
"Ancestor chain length mismatch for file '{}': Alice has {} ancestors, Bob has {}",
alice_file.name,
alice_ancestor_uuids.len(),
bob_ancestor_uuids.len()
);
for (i, ((alice_name, alice_uuid), (bob_name, bob_uuid))) in alice_ancestor_uuids
.iter()
.zip(bob_ancestor_uuids.iter())
.enumerate()
{
assert_eq!(
alice_uuid, bob_uuid,
"Ancestor UUID mismatch at level {} for file '{}': Alice has '{}' ({}), Bob has '{}' ({})",
i,
alice_file.name,
alice_name,
alice_uuid,
bob_name,
bob_uuid
);
}
// Verify closure table has all ancestor relationships on Bob
for (_ancestor_name, ancestor_uuid) in &alice_ancestor_uuids {
// Find ancestor entry on Bob by UUID
let bob_ancestor = entities::entry::Entity::find()
.filter(entities::entry::Column::Uuid.eq(*ancestor_uuid))
.one(library_bob.db().conn())
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"Bob missing ancestor with UUID {} for file '{}'",
ancestor_uuid,
alice_file.name
)
})?;
// Verify closure table entry exists
let closure_entry = entities::entry_closure::Entity::find()
.filter(entities::entry_closure::Column::AncestorId.eq(bob_ancestor.id))
.filter(entities::entry_closure::Column::DescendantId.eq(bob_file.id))
.one(library_bob.db().conn())
.await?;
assert!(
closure_entry.is_some(),
"Closure table missing entry on Bob: ancestor '{}' ({}) -> descendant '{}' ({})",
bob_ancestor.name,
bob_ancestor.id,
bob_file.name,
bob_file.id
);
}
verified_count += 1;
tracing::debug!(
file_name = alice_file.name,
depth = depth,
ancestor_count = alice_ancestor_uuids.len(),
"Nested file structure verified"
);
// Stop after verifying 5 nested files to keep test time reasonable
if verified_count >= 5 {
break;
}
}
assert!(
nested_files_checked >= 2,
"Not enough nested files found for verification (found {}, need at least 2)",
nested_files_checked
);
assert!(
verified_count >= 2,
"Not enough nested files verified (verified {}, need at least 2)",
verified_count
);
tracing::info!(
verified_count = verified_count,
"✅ Nested file structure and ancestor chains verified"
);
Ok(())
}

View File

@@ -5,7 +5,7 @@
mod helpers;
use helpers::MockTransport;
use helpers::{MockTransport, TestDataDir};
use sd_core::{
infra::{
db::entities,
@@ -22,7 +22,7 @@ use uuid::Uuid;
/// Test harness for event log testing
struct EventLogTestHarness {
data_dir_alice: PathBuf,
_test_data_alice: TestDataDir,
core_alice: Core,
library_alice: Arc<Library>,
device_alice_id: Uuid,
@@ -32,26 +32,16 @@ struct EventLogTestHarness {
impl EventLogTestHarness {
async fn new(test_name: &str) -> anyhow::Result<Self> {
// Create test directories
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let test_root = std::path::PathBuf::from(home)
.join("Library/Application Support/spacedrive/event_log_tests");
// Use unique data directory per test with timestamp to avoid any conflicts
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S_%f");
let data_dir = test_root
.join("data")
.join(format!("{}_{}", test_name, timestamp));
fs::create_dir_all(&data_dir).await?;
let temp_dir_alice = data_dir.join("alice");
fs::create_dir_all(&temp_dir_alice).await?;
// Use TestDataDir helper for proper cross-platform directory management
let test_data_alice = TestDataDir::new(format!("event_log_{}", test_name))?;
let temp_dir_alice = test_data_alice.core_data_path();
// Create snapshot directory
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let snapshot_dir = test_root
let snapshot_dir = test_data_alice
.path()
.join("snapshots")
.join(format!("{}_{}", test_name, timestamp));
.join(timestamp.to_string());
fs::create_dir_all(&snapshot_dir).await?;
// Initialize tracing
@@ -60,6 +50,12 @@ impl EventLogTestHarness {
.with_env_filter("sd_core::service::sync=debug,sd_core::infra::sync::event_log=trace")
.try_init();
tracing::info!(
test_data_dir = %test_data_alice.path().display(),
snapshot_dir = %snapshot_dir.display(),
"Event log test initialized"
);
// Initialize core
let core_alice = Core::new(temp_dir_alice.clone())
.await
@@ -92,7 +88,7 @@ impl EventLogTestHarness {
);
Ok(Self {
data_dir_alice: temp_dir_alice,
_test_data_alice: test_data_alice,
core_alice,
library_alice,
device_alice_id,

View File

@@ -5,7 +5,7 @@
//!
//! ## Features
//! - Pre-paired devices (Alice & Bob)
//! - Indexes real folders
//! - Indexes Spacedrive source code for deterministic testing
//! - Event-driven architecture
//! - Captures sync logs, databases, and event bus events
//! - Timestamped snapshot folders for each run
@@ -39,9 +39,13 @@ async fn test_realtime_sync_alice_to_bob() -> anyhow::Result<()> {
// Phase 1: Add location on Alice
tracing::info!("=== Phase 1: Adding location on Alice ===");
let desktop_path = std::env::var("HOME").unwrap() + "/Desktop";
// Use Spacedrive source code for deterministic testing across all environments
let test_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let location_uuid = harness
.add_and_index_location_alice(&desktop_path, "Desktop")
.add_and_index_location_alice(test_path.to_str().unwrap(), "spacedrive")
.await?;
tracing::info!(
@@ -144,9 +148,14 @@ async fn test_realtime_sync_bob_to_alice() -> anyhow::Result<()> {
.await?;
// Add location on Bob (reverse direction)
let downloads_path = std::env::var("HOME").unwrap() + "/Downloads";
// Use Spacedrive crates directory for deterministic testing
let project_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let crates_path = project_root.join("crates");
harness
.add_and_index_location_bob(&downloads_path, "Downloads")
.add_and_index_location_bob(crates_path.to_str().unwrap(), "crates")
.await?;
// Wait for sync
@@ -184,12 +193,17 @@ async fn test_concurrent_indexing() -> anyhow::Result<()> {
.await?;
// Add different locations on both devices simultaneously
let downloads_path = std::env::var("HOME").unwrap() + "/Downloads";
let desktop_path = std::env::var("HOME").unwrap() + "/Desktop";
// Use Spacedrive source code for deterministic testing
let project_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let core_path = project_root.join("core");
let apps_path = project_root.join("apps");
// Start indexing on both
let alice_task = harness.add_and_index_location_alice(&downloads_path, "Downloads");
let bob_task = harness.add_and_index_location_bob(&desktop_path, "Desktop");
let alice_task = harness.add_and_index_location_alice(core_path.to_str().unwrap(), "core");
let bob_task = harness.add_and_index_location_bob(apps_path.to_str().unwrap(), "apps");
// Wait for both
tokio::try_join!(alice_task, bob_task)?;
@@ -223,9 +237,14 @@ async fn test_content_identity_linkage() -> anyhow::Result<()> {
.await?;
// Index on Alice
let downloads_path = std::env::var("HOME").unwrap() + "/Downloads";
// Use Spacedrive docs directory for deterministic testing
let project_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let docs_path = project_root.join("docs");
harness
.add_and_index_location_alice(&downloads_path, "Downloads")
.add_and_index_location_alice(docs_path.to_str().unwrap(), "docs")
.await?;
// Wait for content identification to complete

View File

@@ -42,11 +42,9 @@ async fn find_entry_by_name(
#[tokio::test]
async fn test_tagging_persists_to_database() {
// Use a clean, test-scoped data directory
let data_dir = std::path::PathBuf::from("core/data/tagging-persistence-test");
if data_dir.exists() {
std::fs::remove_dir_all(&data_dir).unwrap();
}
// Use a clean, test-scoped data directory in temp
let temp_data = TempDir::new().unwrap();
let data_dir = temp_data.path().join("core_data");
std::fs::create_dir_all(&data_dir).unwrap();
// Init Core and a fresh library

View File

@@ -15,6 +15,7 @@ Spacedrive Core provides two primary testing approaches:
### Test Organization
Tests live in two locations:
- `core/tests/` - Integration tests that verify complete workflows
- `core/src/testing/` - Test framework utilities and helpers
@@ -27,12 +28,12 @@ For single-device tests, use Tokio's async test framework:
async fn test_library_creation() {
let setup = IntegrationTestSetup::new("library_test").await.unwrap();
let core = setup.create_core().await.unwrap();
let library = core.libraries
.create_library("Test Library", None)
.await
.unwrap();
assert!(!library.id.is_empty());
}
```
@@ -55,6 +56,7 @@ let setup = IntegrationTestSetup::with_config("test_name", |builder| {
```
Key features:
- Isolated temporary directories per test
- Structured logging to `test_data/{test_name}/library/logs/`
- Automatic cleanup on drop
@@ -67,6 +69,7 @@ Spacedrive provides two approaches for testing multi-device scenarios:
### When to Use Subprocess Framework
**Use `CargoTestRunner` subprocess framework when:**
- Testing **real networking** with actual network discovery, NAT traversal, and connections
- Testing **device pairing** workflows that require independent network stacks
- Scenarios need **true process isolation** (separate memory spaces, different ports)
@@ -85,6 +88,7 @@ let mut runner = CargoTestRunner::new()
### When to Use Custom Transport/Harness
**Use custom harness with mock transport when:**
- Testing **sync logic** without network overhead
- Fast iteration on **data synchronization** algorithms
- Testing **deterministic scenarios** without network timing issues
@@ -103,14 +107,14 @@ let harness = TwoDeviceHarnessBuilder::new("sync_test")
### Comparison
| Aspect | Subprocess Framework | Custom Harness |
|--------|---------------------|----------------|
| **Speed** | Slower (real networking) | Fast (in-memory) |
| **Networking** | Real (discovery, NAT) | Mock transport |
| **Isolation** | True process isolation | Shared process |
| **Debugging** | Harder (multiple processes) | Easier (single process) |
| **Determinism** | Network timing varies | Fully deterministic |
| **Use Case** | Network features | Sync/data logic |
| Aspect | Subprocess Framework | Custom Harness |
| --------------- | --------------------------- | ----------------------- |
| **Speed** | Slower (real networking) | Fast (in-memory) |
| **Networking** | Real (discovery, NAT) | Mock transport |
| **Isolation** | True process isolation | Shared process |
| **Debugging** | Harder (multiple processes) | Easier (single process) |
| **Determinism** | Network timing varies | Fully deterministic |
| **Use Case** | Network features | Sync/data logic |
## Subprocess Testing Framework
@@ -137,7 +141,7 @@ async fn test_device_pairing() {
let mut runner = CargoTestRunner::new()
.add_subprocess("alice", "alice_pairing")
.add_subprocess("bob", "bob_pairing");
runner.run_until_success(|outputs| {
outputs.values().all(|o| o.contains("PAIRING_SUCCESS"))
}).await.unwrap();
@@ -149,14 +153,14 @@ async fn alice_pairing() {
if env::var("TEST_ROLE").unwrap_or_default() != "alice" {
return;
}
let data_dir = PathBuf::from(env::var("TEST_DATA_DIR").unwrap());
let core = create_test_core(data_dir).await.unwrap();
// Alice initiates pairing
let (code, _) = core.start_pairing_as_initiator().await.unwrap();
fs::write("/tmp/pairing_code.txt", &code).unwrap();
// Wait for connection
wait_for_connection(&core).await;
println!("PAIRING_SUCCESS");
@@ -164,12 +168,14 @@ async fn alice_pairing() {
```
<Note>
Device scenario functions must be marked with `#[ignore]` to prevent direct execution. They only run when called by the subprocess framework.
Device scenario functions must be marked with `#[ignore]` to prevent direct
execution. They only run when called by the subprocess framework.
</Note>
### Process Coordination
Processes coordinate through:
- **Environment variables**: `TEST_ROLE` and `TEST_DATA_DIR`
- **Temporary files**: Share data like pairing codes
- **Output patterns**: Success markers for the runner to detect
@@ -240,7 +246,8 @@ watcher.watch_ephemeral(dest_dir.clone()).await?;
```
<Note>
The `IndexerJob` automatically calls `watch_ephemeral()` after successful indexing, so manual registration is only needed when bypassing the indexer.
The `IndexerJob` automatically calls `watch_ephemeral()` after successful
indexing, so manual registration is only needed when bypassing the indexer.
</Note>
#### Persistent Location Watching
@@ -290,6 +297,7 @@ assert!(stats.resource_changed.get("file").copied().unwrap_or(0) >= 2);
```
The `EventCollector` automatically filters out:
- Library statistics updates (`LibraryStatisticsUpdated`)
- Library resource events (non-file/entry events)
@@ -365,12 +373,14 @@ let indexing_events = collector.get_events_by_type("IndexingCompleted").await;
```
The `EventCollector` tracks:
- **ResourceChanged/ResourceChangedBatch** events by resource type
- **Indexing** start/completion events
- **Job** lifecycle events (started/completed)
- **Entry** events (created/modified/deleted/moved)
**Statistics Output:**
```
Event Statistics:
==================
@@ -394,6 +404,7 @@ Job events:
```
**Detailed Event Output (with `with_capture()`):**
```
=== Collected Events (8) ===
@@ -418,6 +429,7 @@ Job events:
```
**Use Cases:**
- Verifying watcher events during file operations
- Testing normalized cache updates
- Debugging event emission patterns
@@ -449,7 +461,7 @@ let job_id = core.jobs.dispatch(IndexingJob::new(...)).await?;
// Monitor progress
wait_for_event(&mut events, |e| matches!(
e,
e,
Event::JobProgress { id, .. } if *id == job_id
), timeout).await?;
@@ -476,6 +488,196 @@ perform_operation_on_a(&core_a).await?;
wait_for_sync(&core_b).await?;
```
## Test Data & Snapshot Conventions
### Data Directory Requirements
All test data MUST be created in the system temp directory. Never persist data outside temp unless using the snapshot flag.
**Naming convention**: `spacedrive-test-{test_name}`
```rust
// ✅ CORRECT: Platform-aware temp directory
let test_data = TestDataDir::new("file_operations")?;
// Creates: /tmp/spacedrive-test-file_operations/ (Unix)
// or: %TEMP%\spacedrive-test-file_operations\ (Windows)
// ❌ INCORRECT: Hardcoded paths outside temp
let test_dir = PathBuf::from("~/Library/Application Support/spacedrive/tests");
let test_dir = PathBuf::from("core/data/test");
```
**Standard structure**:
```
/tmp/spacedrive-test-{test_name}/
├── core_data/ # Core database and state
├── locations/ # Test file locations
└── logs/ # Test execution logs
```
**Cleanup**: Temp directories are automatically cleaned up after test completion using RAII pattern.
### Snapshot System
Snapshots preserve test state for post-mortem debugging. They are optional and controlled by an environment variable.
**Enable snapshots**:
```bash
# Single test
SD_TEST_SNAPSHOTS=1 cargo test file_move_test --nocapture
# Entire suite
SD_TEST_SNAPSHOTS=1 cargo xtask test-core
```
**Snapshot location** (when enabled):
```
~/Library/Application Support/spacedrive/test_snapshots/ (macOS)
~/.local/share/spacedrive/test_snapshots/ (Linux)
%APPDATA%\spacedrive\test_snapshots\ (Windows)
```
**Structure**:
```
test_snapshots/
└── {test_name}/
└── {timestamp}/
├── summary.md # Test metadata and statistics
├── core_data/ # Database copies
│ ├── database.db
│ └── sync.db
├── events.json # Event bus events (JSON lines)
└── logs/ # Test execution logs
```
**When to use snapshots**:
- Debugging sync tests (database state, event logs)
- Complex indexing scenarios (closure table analysis)
- Multi-phase operations (capture state at each phase)
- Investigating flaky tests
**Not needed for**:
- Simple unit tests
- Tests with assertion-only validation
- Tests where console output is sufficient
### Fixture Generation
Some tests generate fixtures used by other test suites (e.g., TypeScript tests consuming Rust-generated event data). These fixtures follow the same conventions as snapshots: always write to temp, only copy to source when explicitly requested.
**Generate fixtures**:
```bash
# Single fixture test
SD_REGENERATE_FIXTURES=1 cargo test normalized_cache_fixtures_test --nocapture
```
**Fixture location** (when enabled):
```
packages/ts-client/src/__fixtures/backend_events.json (TypeScript test fixtures)
```
**Default behavior**:
- Fixtures written to temp directory
- Test validates generation works
- No modification of source tree
**When `SD_REGENERATE_FIXTURES=1` is set**:
- Fixtures generated in temp first (validation)
- Copied to source tree for commit
- Used by TypeScript tests
**Example fixture test**:
```rust
#[tokio::test]
async fn generate_typescript_fixtures() -> Result<()> {
let temp_dir = TempDir::new()?;
// Generate fixture data
let fixture_data = generate_real_backend_events().await?;
// Always write to temp
let temp_fixture_path = temp_dir.path().join("backend_events.json");
std::fs::write(&temp_fixture_path, serde_json::to_string_pretty(&fixture_data)?)?;
// Only copy to source if explicitly requested
if std::env::var("SD_REGENERATE_FIXTURES").is_ok() {
let source_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap()
.join("packages/ts-client/src/__fixtures__/backend_events.json");
std::fs::copy(&temp_fixture_path, &source_path)?;
println!("Fixtures copied to source: {}", source_path.display());
}
Ok(())
}
```
**When to regenerate fixtures**:
- Backend event format changes
- TypeScript types updated
- New query responses added
- Resource change events modified
### Helper Abstractions
**TestDataDir** - Manages test data directories with automatic cleanup and snapshot support:
```rust
#[tokio::test]
async fn test_file_operations() -> Result<()> {
let test_data = TestDataDir::new("file_operations")?;
let core = Core::new(test_data.core_data_path()).await?;
// Perform test operations...
// Optional: capture snapshot at specific point
if let Some(manager) = test_data.snapshot_manager() {
manager.capture("after_indexing").await?;
}
// Automatic cleanup and final snapshot (if enabled) on drop
Ok(())
}
```
**SnapshotManager** - Captures test snapshots (accessed via `TestDataDir`):
```rust
// Multi-phase snapshot capture
if let Some(manager) = test_data.snapshot_manager() {
manager.capture("after_setup").await?;
manager.capture("after_sync").await?;
manager.capture("final_state").await?;
}
```
**Integration with existing harnesses**:
```rust
// IndexingHarness uses TestDataDir internally
let harness = IndexingHarnessBuilder::new("my_test").build().await?;
// Access snapshot manager through harness
if let Some(manager) = harness.snapshot_manager() {
manager.capture("after_indexing").await?;
}
// TwoDeviceHarness has built-in snapshot method
harness.capture_snapshot("after_sync").await?;
```
## Test Helpers
### Common Utilities
@@ -483,27 +685,32 @@ wait_for_sync(&core_b).await?;
The framework provides comprehensive test helpers in `core/tests/helpers/`:
**Event Collection:**
- `EventCollector` - Collect and analyze all events from the event bus
- `EventStats` - Statistics about collected events with formatted output
**Indexing Tests:**
- `IndexingHarnessBuilder` - Create isolated test environments with indexing support
- `TestLocation` - Builder for test locations with files
- `LocationHandle` - Handle to indexed locations with verification methods
**Sync Tests:**
- `TwoDeviceHarnessBuilder` - Pre-configured two-device sync test environments
- `MockTransport` - Mock network transport for deterministic sync testing
- `wait_for_sync()` - Sophisticated sync completion detection
- `TestConfigBuilder` - Custom test configurations
**Database & Jobs:**
- `wait_for_event()` - Wait for specific events with timeout
- `wait_for_indexing()` - Wait for indexing job completion
- `register_device()` - Register a device in a library
<Tip>
See `core/tests/helpers/README.md` for detailed documentation on all available helpers including usage examples and migration guides.
See `core/tests/helpers/README.md` for detailed documentation on all available
helpers including usage examples and migration guides.
</Tip>
### Test Volumes
@@ -518,25 +725,201 @@ let volume = test_volumes::create_test_volume().await?;
test_volumes::cleanup_test_volume(volume).await?;
```
## Core Integration Test Suite
Spacedrive maintains a curated suite of core integration tests that run in CI and during local development. These tests are defined in a single source of truth using the `xtask` pattern.
### Running the Core Test Suite
The `cargo xtask test-core` command runs all core integration tests with progress tracking:
```bash
# Run all core tests (minimal output)
cargo xtask test-core
# Run with full test output
cargo xtask test-core --verbose
```
**Example output:**
```
════════════════════════════════════════════════════════════════
Spacedrive Core Tests Runner
Running 13 test suite(s)
════════════════════════════════════════════════════════════════
[1/13] Running: Library tests
────────────────────────────────────────────────────────────────
✓ PASSED (2s)
[2/13] Running: Indexing test
────────────────────────────────────────────────────────────────
✓ PASSED (15s)
...
════════════════════════════════════════════════════════════════
Test Results Summary
════════════════════════════════════════════════════════════════
Total time: 7m 24s
✓ Passed (11/13):
✓ Library tests
✓ Indexing test
...
✗ Failed (2/13):
✗ Sync realtime test
✗ File sync test
```
### Single Source of Truth
All core integration tests are defined in `xtask/src/test_core.rs` in the `CORE_TESTS` constant:
```rust
pub const CORE_TESTS: &[TestSuite] = &[
TestSuite {
name: "Library tests",
args: &["test", "-p", "sd-core", "--lib", "--", "--test-threads=1"],
},
TestSuite {
name: "Indexing test",
args: &["test", "-p", "sd-core", "--test", "indexing_test", "--", "--test-threads=1"],
},
// ... more tests
];
```
**Benefits:**
- CI and local development use identical test definitions
- Add or remove tests in one place
- Automatic progress tracking and result summary
- Continues running even if some tests fail
### CI Integration
The GitHub Actions workflow runs the core test suite on all platforms:
```yaml
# .github/workflows/core_tests.yml
- name: Run all tests
run: cargo xtask test-core --verbose
```
Tests run in parallel on:
- **macOS** (ARM64 self-hosted)
- **Linux** (Ubuntu 22.04)
- **Windows** (latest)
With `fail-fast: false`, all platforms complete even if one fails.
### Deterministic Test Data
Core integration tests use the Spacedrive source code itself as test data instead of user directories. This ensures:
- **Consistent results** across all machines and CI
- **No user data access** required
- **Cross-platform compatibility** without setup
- **Predictable file structure** for test assertions
```rust
// Tests index the Spacedrive project root
let test_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let location = harness
.add_and_index_location(test_path.to_str().unwrap(), "spacedrive")
.await?;
```
Tests that need multiple locations use different subdirectories:
```rust
let project_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let core_path = project_root.join("core");
let apps_path = project_root.join("apps");
```
### Adding Tests to the Suite
To add a new test to the core suite:
1. Create your test in `core/tests/your_test.rs`
2. Add it to `CORE_TESTS` in `xtask/src/test_core.rs`:
```rust
pub const CORE_TESTS: &[TestSuite] = &[
// ... existing tests
TestSuite {
name: "Your new test",
args: &[
"test",
"-p",
"sd-core",
"--test",
"your_test",
"--",
"--test-threads=1",
"--nocapture",
],
},
];
```
The test will automatically:
- Run in CI on all platforms
- Appear in `cargo xtask test-core` output
- Show in progress tracking and summary
<Note>
Core integration tests use `--test-threads=1` to avoid conflicts when
accessing the same locations or performing filesystem operations.
</Note>
## Running Tests
### All Tests
```bash
cargo test --workspace
```
### Core Integration Tests
```bash
# Run curated core test suite
cargo xtask test-core
# With full output
cargo xtask test-core --verbose
```
### Specific Test
```bash
cargo test test_device_pairing --nocapture
```
### Debug Subprocess Tests
```bash
# Run individual scenario
TEST_ROLE=alice TEST_DATA_DIR=/tmp/test cargo test alice_scenario -- --ignored --nocapture
```
### With Logging
```bash
RUST_LOG=debug cargo test test_name --nocapture
```
@@ -548,6 +931,32 @@ RUST_LOG=debug cargo test test_name --nocapture
1. **Use descriptive names**: `test_cross_device_file_transfer` over `test_transfer`
2. **One concern per test**: Focus on a single feature or workflow
3. **Clean up resources**: Use RAII patterns or explicit cleanup
4. **Use deterministic test data**: Index Spacedrive source code instead of user directories
### Test Data
1. **All test data in temp directory**: Use `TestDataDir` or `TempDir` (see Test Data & Snapshot Conventions)
2. **Prefer project source code**: Use `env!("CARGO_MANIFEST_DIR")` to locate the Spacedrive repo for test indexing
3. **Avoid user directories**: Don't hardcode paths like `$HOME/Desktop` or `$HOME/Downloads`
4. **Use subdirectories for multiple locations**: `core/`, `apps/`, etc. when testing multi-location scenarios
5. **Cross-platform paths**: Ensure test paths work on Linux, macOS, and Windows
```rust
// ✅ Good: Platform-aware temp directory for test data
let test_data = TestDataDir::new("my_test")?;
// ✅ Good: Uses project source code for deterministic indexing
let test_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
// ❌ Bad: Data outside temp directory
let test_dir = PathBuf::from("core/data/test");
// ❌ Bad: Uses user directory (non-deterministic)
let desktop_path = std::env::var("HOME").unwrap() + "/Desktop";
```
### Subprocess Tests
@@ -559,10 +968,12 @@ RUST_LOG=debug cargo test test_name --nocapture
### Debugging
<Tip>
When tests fail, check the logs in `test_data/{test_name}/library/logs/` for detailed information about what went wrong.
When tests fail, check the logs in `test_data/{test_name}/library/logs/` for
detailed information about what went wrong.
</Tip>
Common debugging approaches:
- Run with `--nocapture` to see all output
- Check job logs in `test_data/{test_name}/library/job_logs/`
- Run scenarios individually with manual environment variables
@@ -579,10 +990,11 @@ Common debugging approaches:
### Single-Device Test Checklist
- [ ] Create test with `#[tokio::test]`
- [ ] Use `IntegrationTestSetup` for isolation
- [ ] Use `TestDataDir` or harness for test data (never hardcode paths outside temp)
- [ ] Use deterministic test data for indexing (project source code, not user directories)
- [ ] Wait for events instead of sleeping
- [ ] Verify both positive and negative cases
- [ ] Clean up temporary files
- [ ] Automatic cleanup via RAII pattern (no manual cleanup needed with helpers)
### Multi-Device Test Checklist
@@ -592,6 +1004,17 @@ Common debugging approaches:
- [ ] Define clear success patterns
- [ ] Handle process coordination properly
- [ ] Set reasonable timeouts
- [ ] Use deterministic test data for cross-platform compatibility
### Core Integration Test Checklist
When adding a test to the core suite (`cargo xtask test-core`):
- [ ] Test uses deterministic data (Spacedrive source code)
- [ ] Test runs reliably on Linux, macOS, and Windows
- [ ] Test includes `--test-threads=1` if accessing shared resources
- [ ] Add test definition to `xtask/src/test_core.rs`
- [ ] Verify test runs successfully in CI workflow
## TypeScript Integration Testing
@@ -676,7 +1099,8 @@ async fn test_typescript_cache_updates() -> anyhow::Result<()> {
```
<Note>
Use `.enable_daemon()` on `IndexingHarnessBuilder` to start the RPC server. The daemon listens on a random TCP port returned by `.daemon_socket_addr()`.
Use `.enable_daemon()` on `IndexingHarnessBuilder` to start the RPC server.
The daemon listens on a random TCP port returned by `.daemon_socket_addr()`.
</Note>
#### TypeScript Side
@@ -692,60 +1116,61 @@ import { SpacedriveProvider } from "../../src/hooks/useClient";
import { useNormalizedQuery } from "../../src/hooks/useNormalizedQuery";
interface BridgeConfig {
socket_addr: string;
library_id: string;
location_db_id: number;
location_path: string;
test_data_path: string;
socket_addr: string;
library_id: string;
location_db_id: number;
location_path: string;
test_data_path: string;
}
let bridgeConfig: BridgeConfig;
let client: SpacedriveClient;
beforeAll(async () => {
// Read bridge config from Rust test
const configPath = process.env.BRIDGE_CONFIG_PATH;
const configJson = await readFile(configPath, "utf-8");
bridgeConfig = JSON.parse(configJson);
// Read bridge config from Rust test
const configPath = process.env.BRIDGE_CONFIG_PATH;
const configJson = await readFile(configPath, "utf-8");
bridgeConfig = JSON.parse(configJson);
// Connect to daemon via TCP socket
client = SpacedriveClient.fromTcpSocket(bridgeConfig.socket_addr);
client.setCurrentLibrary(bridgeConfig.library_id);
// Connect to daemon via TCP socket
client = SpacedriveClient.fromTcpSocket(bridgeConfig.socket_addr);
client.setCurrentLibrary(bridgeConfig.library_id);
});
describe("Cache Update Tests", () => {
test("should update cache when files move", async () => {
const wrapper = ({ children }) =>
React.createElement(SpacedriveProvider, { client }, children);
test("should update cache when files move", async () => {
const wrapper = ({ children }) =>
React.createElement(SpacedriveProvider, { client }, children);
// Query directory listing with useNormalizedQuery
const { result } = renderHook(
() => useNormalizedQuery({
wireMethod: "query:files.directory_listing",
input: { path: { Physical: { path: folderPath } } },
resourceType: "file",
pathScope: { Physical: { path: folderPath } },
debug: true, // Enable debug logging
}),
{ wrapper }
);
// Query directory listing with useNormalizedQuery
const { result } = renderHook(
() =>
useNormalizedQuery({
wireMethod: "query:files.directory_listing",
input: { path: { Physical: { path: folderPath } } },
resourceType: "file",
pathScope: { Physical: { path: folderPath } },
debug: true, // Enable debug logging
}),
{ wrapper },
);
// Wait for initial data
await waitFor(() => {
expect(result.current.data).toBeDefined();
});
// Wait for initial data
await waitFor(() => {
expect(result.current.data).toBeDefined();
});
// Perform file operation
await rename(oldPath, newPath);
// Perform file operation
await rename(oldPath, newPath);
// Wait for watcher to detect change (500ms buffer + processing)
await new Promise(resolve => setTimeout(resolve, 2000));
// Wait for watcher to detect change (500ms buffer + processing)
await new Promise((resolve) => setTimeout(resolve, 2000));
// Verify cache updated
expect(result.current.data.files).toContainEqual(
expect.objectContaining({ name: "newfile" })
);
});
// Verify cache updated
expect(result.current.data.files).toContainEqual(
expect.objectContaining({ name: "newfile" }),
);
});
});
```
@@ -764,6 +1189,7 @@ const client = new SpacedriveClient(transport);
```
The TCP transport:
- Uses JSON-RPC 2.0 over TCP
- Supports WebSocket-style subscriptions for events
- Automatically reconnects on connection loss
@@ -783,26 +1209,35 @@ The primary use case for bridge tests is verifying that `useNormalizedQuery` cac
```typescript
// Enable debug logging
const { result } = renderHook(
() => useNormalizedQuery({
wireMethod: "query:files.directory_listing",
input: { /* ... */ },
resourceType: "file",
pathScope: { /* ... */ },
debug: true, // Logs event processing
}),
{ wrapper }
() =>
useNormalizedQuery({
wireMethod: "query:files.directory_listing",
input: {
/* ... */
},
resourceType: "file",
pathScope: {
/* ... */
},
debug: true, // Logs event processing
}),
{ wrapper },
);
// Collect all events for debugging
const allEvents: any[] = [];
const originalCreateSubscription = (client as any).subscriptionManager.createSubscription;
(client as any).subscriptionManager.createSubscription = function(filter: any, callback: any) {
const wrappedCallback = (event: any) => {
allEvents.push({ timestamp: new Date().toISOString(), event });
console.log(`Event received:`, JSON.stringify(event, null, 2));
callback(event);
};
return originalCreateSubscription.call(this, filter, wrappedCallback);
const originalCreateSubscription = (client as any).subscriptionManager
.createSubscription;
(client as any).subscriptionManager.createSubscription = function (
filter: any,
callback: any,
) {
const wrappedCallback = (event: any) => {
allEvents.push({ timestamp: new Date().toISOString(), event });
console.log(`Event received:`, JSON.stringify(event, null, 2));
callback(event);
};
return originalCreateSubscription.call(this, filter, wrappedCallback);
};
```
@@ -821,24 +1256,29 @@ BRIDGE_CONFIG_PATH=/path/to/config.json bun test tests/integration/mytest.test.t
```
<Tip>
Use `--nocapture` to see TypeScript test output. The Rust test prints all stdout/stderr from the TypeScript test process.
Use `--nocapture` to see TypeScript test output. The Rust test prints all
stdout/stderr from the TypeScript test process.
</Tip>
### Common Scenarios
**File moves between folders:**
- Tests that files removed from one directory appear in another
- Verifies UUID preservation (move detection vs delete+create)
**Folder renames:**
- Tests that nested files update their paths correctly
- Verifies parent path updates propagate to descendants
**Bulk operations:**
- Tests 20+ file moves with mixed Physical/Content paths
- Verifies cache updates don't miss files during batched events
**Content-addressed files:**
- Uses `IndexMode::Content` to enable content identification
- Tests that files with `alternate_paths` update correctly
- Verifies metadata-only updates don't add duplicate cache entries
@@ -846,17 +1286,20 @@ Use `--nocapture` to see TypeScript test output. The Rust test prints all stdout
### Debugging Bridge Tests
**Check Rust logs:**
```bash
RUST_LOG=debug cargo test typescript_bridge -- --nocapture
```
**Check TypeScript output:**
The Rust test prints all TypeScript stdout/stderr. Look for:
- `[TS]` prefixed log messages
- Event payloads with `🔔` emoji
- Final event summary at test end
**Verify daemon is running:**
```bash
# In Rust test output, look for:
Socket address: 127.0.0.1:XXXXX
@@ -864,12 +1307,14 @@ Library ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
```
**Check bridge config:**
```bash
# The config file is written to test_data directory
cat /tmp/test_data/typescript_bridge_test/bridge_config.json
```
**Common issues:**
- **TypeScript test times out**: Increase watcher wait time (filesystem events can be slow)
- **Cache not updating**: Enable `debug: true` to see if events are received
- **Connection refused**: Verify daemon started with `.enable_daemon()`
@@ -879,20 +1324,30 @@ cat /tmp/test_data/typescript_bridge_test/bridge_config.json
For complete examples, refer to:
**Core Test Infrastructure:**
- `xtask/src/test_core.rs` - Single source of truth for all core integration tests
- `.github/workflows/core_tests.yml` - CI workflow using xtask test runner
**Single Device Tests:**
- `tests/copy_action_test.rs` - Event collection during file operations (persistent + ephemeral)
- `tests/job_resumption_integration_test.rs` - Job interruption handling
**Subprocess Framework (Real Networking):**
- `tests/device_pairing_test.rs` - Device pairing with real network discovery
**Custom Harness (Mock Transport):**
- `tests/sync_realtime_test.rs` - Real-time sync testing with deterministic transport
- `tests/sync_integration_test.rs` - Complex sync scenarios with mock networking
- `tests/sync_realtime_test.rs` - Real-time sync testing with deterministic transport using Spacedrive source code
- `tests/sync_backfill_test.rs` - Backfill sync with deterministic test data
- `tests/sync_backfill_race_test.rs` - Race condition testing with concurrent operations
- `tests/file_transfer_test.rs` - Cross-device file operations
**TypeScript Bridge Tests:**
- `tests/typescript_bridge_test.rs` - Rust harness that spawns TypeScript tests
- `packages/ts-client/tests/integration/useNormalizedQuery.test.ts` - File move cache updates
- `packages/ts-client/tests/integration/useNormalizedQuery.folder-rename.test.ts` - Folder rename propagation
- `packages/ts-client/tests/integration/useNormalizedQuery.bulk-moves.test.ts` - Bulk operations with content-addressed files
- `packages/ts-client/tests/integration/useNormalizedQuery.bulk-moves.test.ts` - Bulk operations with content-addressed files

View File

@@ -23,7 +23,17 @@
z-index: 9999;
}
/* Scrollbar styling */
/* Hide all scrollbars globally */
*::-webkit-scrollbar {
display: none;
}
* {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Legacy class for explicit scrollbar hiding (still works) */
.no-scrollbar::-webkit-scrollbar {
display: none;
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -256,6 +256,17 @@ if ($LASTEXITCODE -ne 0) {
Exit-WithError "Something went wrong, exit code: $LASTEXITCODE"
}
# Run xtask setup to download native dependencies and configure cargo
if (-not $env:CI) {
Write-Host
Write-Host 'Running cargo xtask setup to download native dependencies...' -ForegroundColor Yellow
Set-Location $projectRoot
cargo xtask setup
if ($LASTEXITCODE -ne 0) {
Exit-WithError 'Failed to run cargo xtask setup'
}
}
if (-not $env:CI) {
Write-Host
Write-Host 'Your machine has been setup for Spacedrive development!' -ForegroundColor Green

View File

@@ -8,6 +8,7 @@ version = "0.1.0"
anyhow = "1"
flate2 = "1.0"
mustache = "0.9"
owo-colors = "4"
reqwest = { version = "0.12", features = ["blocking", "rustls-tls"], default-features = false }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@@ -38,6 +38,31 @@ cargo ios
## Available Commands
### `test-core`
**Single source of truth for core integration tests!**
Runs all sd-core integration tests with progress tracking and result summary.
This command is used by both CI and local development, ensuring consistency.
**Usage:**
```bash
cargo xtask test-core # Run with minimal output
cargo xtask test-core --verbose # Show full test output
```
**Features:**
- Progress tracking (shows which test is running)
- Timing for each test suite
- Summary report showing passed/failed tests
- Same test definitions used in CI workflows
- Continues running even if some tests fail
All tests are defined in `xtask/src/test_core.rs` as the single source of truth.
Add or remove tests there and they automatically apply to both CI and local runs.
### `setup`
**Replaces `pnpm prep` with a pure Rust implementation!**
@@ -51,11 +76,13 @@ Sets up your development environment:
5. Generates `.cargo/config.toml` from the template
**Usage:**
```bash
cargo xtask setup
```
**First time setup:**
```bash
# Install Rust if you haven't already
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
@@ -117,12 +144,14 @@ The `xtask` binary is just a regular Rust program that uses `std::process::Comma
### Replaced: `pnpm prep` (JavaScript)
**Old way:**
```bash
pnpm i # Install JS dependencies
pnpm prep # Run JavaScript setup script
```
**New way:**
```bash
cargo xtask setup # Pure Rust, no JS needed!
```
@@ -130,11 +159,13 @@ cargo xtask setup # Pure Rust, no JS needed!
### Replaced: `scripts/build_ios_xcframework.sh` (Bash)
**Old way:**
```bash
./scripts/build_ios_xcframework.sh
```
**New way:**
```bash
cargo ios # Convenient alias
# or

View File

@@ -27,6 +27,7 @@
mod config;
mod native_deps;
mod system;
mod test_core;
use anyhow::{Context, Result};
use std::fs;
@@ -44,11 +45,13 @@ fn main() -> Result<()> {
);
eprintln!(" build-ios Build sd-ios-core XCFramework for iOS devices and simulator");
eprintln!(" build-mobile Build sd-mobile-core for React Native iOS/Android");
eprintln!(" test-core Run all core integration tests with progress tracking");
eprintln!();
eprintln!("Examples:");
eprintln!(" cargo xtask setup # First time setup");
eprintln!(" cargo xtask build-ios # Build iOS framework");
eprintln!(" cargo xtask build-mobile # Build mobile core for React Native");
eprintln!(" cargo xtask test-core # Run all core tests");
eprintln!(" cargo ios # Convenient alias for build-ios");
std::process::exit(1);
}
@@ -57,6 +60,13 @@ fn main() -> Result<()> {
"setup" => setup()?,
"build-ios" => build_ios()?,
"build-mobile" => build_mobile()?,
"test-core" => {
let verbose = args
.get(2)
.map(|s| s == "--verbose" || s == "-v")
.unwrap_or(false);
test_core_command(verbose)?;
}
_ => {
eprintln!("Unknown command: {}", args[1]);
eprintln!("Run 'cargo xtask' for usage information.");
@@ -213,13 +223,49 @@ fn setup() -> Result<()> {
// Create target-suffixed daemon binary for Tauri bundler
// Tauri's externalBin appends the target triple to binary names
let target_triple = system.target_triple();
let daemon_source = project_root.join("target/release/sd-daemon");
let daemon_target = project_root.join(format!("target/release/sd-daemon-{}", target_triple));
let exe_ext = if cfg!(windows) { ".exe" } else { "" };
let daemon_source = project_root.join(format!("target/release/sd-daemon{}", exe_ext));
let daemon_target = project_root.join(format!(
"target/release/sd-daemon-{}{}",
target_triple, exe_ext
));
if daemon_source.exists() {
fs::copy(&daemon_source, &daemon_target)
.context("Failed to create target-suffixed daemon binary")?;
println!(" ✓ Created sd-daemon-{}", target_triple);
println!(" ✓ Created sd-daemon-{}{}", target_triple, exe_ext);
}
// On Windows, copy DLLs to target directories so executables can find them at runtime
#[cfg(windows)]
{
println!();
println!("Copying DLLs to target directories...");
let dll_source_dir = native_deps_dir.join("bin");
if dll_source_dir.exists() {
// Copy to both debug and release directories
for target_profile in ["debug", "release"] {
let target_dir = project_root.join("target").join(target_profile);
fs::create_dir_all(&target_dir).ok();
if let Ok(entries) = fs::read_dir(&dll_source_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "dll") {
let dest = target_dir.join(path.file_name().unwrap());
if let Err(e) = fs::copy(&path, &dest) {
eprintln!(
" Warning: Failed to copy {}: {}",
path.file_name().unwrap().to_string_lossy(),
e
);
}
}
}
}
println!(" ✓ DLLs copied to target/{}/", target_profile);
}
}
}
println!();
@@ -543,3 +589,24 @@ fn create_framework_info_plist(framework_name: &str, platform: &str) -> String {
framework_name, framework_name, platform
)
}
/// Run all core integration tests with progress tracking
///
/// This command runs all sd-core integration tests defined in test_core.rs.
/// Tests are run sequentially with --test-threads=1 to avoid conflicts.
/// Use --verbose to see full test output.
fn test_core_command(verbose: bool) -> Result<()> {
let results = test_core::run_tests(verbose)?;
let failed_count = results.iter().filter(|r| !r.passed).count();
if failed_count > 0 {
std::process::exit(1);
} else {
if verbose {
println!("All tests passed!");
}
}
Ok(())
}

256
xtask/src/test_core.rs Normal file
View File

@@ -0,0 +1,256 @@
//! Core integration tests runner
//!
//! Single source of truth for all sd-core integration tests. This module defines
//! which tests should run when testing the core, used both by CI and local development.
use anyhow::{Context, Result};
use owo_colors::OwoColorize;
use std::process::Command;
use std::time::Instant;
/// Test suite definition with name and specific test arguments
#[derive(Debug, Clone)]
pub struct TestSuite {
pub name: &'static str,
/// Specific args that go between the common prefix and suffix
pub test_args: &'static [&'static str],
}
impl TestSuite {
/// Build complete cargo test command arguments
pub fn build_args(&self) -> Vec<&str> {
let mut args = vec!["test", "-p", "sd-core"];
args.extend_from_slice(self.test_args);
args.extend_from_slice(&["--", "--test-threads=1", "--nocapture"]);
args
}
}
/// All core integration tests that should run in CI and locally
///
/// This is the single source of truth for which tests to run.
/// Add or remove tests here and they'll automatically apply to both
/// CI workflows and local test scripts.
pub const CORE_TESTS: &[TestSuite] = &[
TestSuite {
name: "All core unit tests",
test_args: &["--lib"],
},
TestSuite {
name: "Database migration test",
test_args: &["--test", "database_migration_test"],
},
TestSuite {
name: "Indexing test",
test_args: &["--test", "indexing_test"],
},
TestSuite {
name: "Indexing rules test",
test_args: &["--test", "indexing_rules_test"],
},
TestSuite {
name: "Indexing responder reindex test",
test_args: &["--test", "indexing_responder_reindex_test"],
},
// TestSuite {
// name: "Sync event log test",
// test_args: &["--test", "sync_event_log_test"],
// },
// TestSuite {
// name: "Sync metrics test",
// test_args: &["--test", "sync_metrics_test"],
// },
// TestSuite {
// name: "Sync realtime test",
// test_args: &["--test", "sync_realtime_test"],
// },
TestSuite {
name: "Sync setup test",
test_args: &["--test", "sync_setup_test"],
},
TestSuite {
name: "File sync simple test",
test_args: &["--test", "file_sync_simple_test"],
},
TestSuite {
name: "File move test",
test_args: &["--test", "file_move_test"],
},
TestSuite {
name: "File copy pull test",
test_args: &["--test", "file_copy_pull_test"],
},
TestSuite {
name: "Entry move integrity test",
test_args: &["--test", "entry_move_integrity_test"],
},
TestSuite {
name: "File structure test",
test_args: &["--test", "file_structure_test"],
},
TestSuite {
name: "Normalized cache fixtures test",
test_args: &["--test", "normalized_cache_fixtures_test"],
},
TestSuite {
name: "Device pairing test",
test_args: &["--test", "device_pairing_test"],
},
TestSuite {
name: "Library test",
test_args: &["--test", "library_test"],
},
TestSuite {
name: "File transfer test",
test_args: &["--test", "file_transfer_test"],
},
TestSuite {
name: "FS watcher test",
test_args: &["--test", "fs_watcher_test"],
},
TestSuite {
name: "Ephemeral watcher test",
test_args: &["--test", "ephemeral_watcher_test"],
},
TestSuite {
name: "Volume detection test",
test_args: &["--test", "volume_detection_test"],
},
TestSuite {
name: "Volume tracking test",
test_args: &["--test", "volume_tracking_test"],
},
TestSuite {
name: "Cross device copy test",
test_args: &["--test", "cross_device_copy_test"],
},
TestSuite {
name: "Typescript bridge test",
test_args: &["--test", "typescript_bridge_test"],
},
TestSuite {
name: "Typescript search bridge test",
test_args: &["--test", "typescript_search_bridge_test"],
},
// TestSuite {
// name: "File sync test",
// test_args: &["--test", "file_sync_test"],
// },
// TestSuite {
// name: "Sync backfill test",
// test_args: &["--test", "sync_backfill_test"],
// },
// TestSuite {
// name: "Sync backfill race test",
// test_args: &["--test", "sync_backfill_race_test"],
// },
];
/// Test result for a single test suite
#[derive(Debug)]
pub struct TestResult {
pub name: String,
pub passed: bool,
}
/// Run all core integration tests with progress tracking
pub fn run_tests(verbose: bool) -> Result<Vec<TestResult>> {
let total_tests = CORE_TESTS.len();
let mut results = Vec::new();
println!();
println!("{}", "Spacedrive Core Tests Runner".bright_cyan().bold());
println!("Running {} test suite(s)\n", total_tests);
let overall_start = Instant::now();
for (index, test_suite) in CORE_TESTS.iter().enumerate() {
let current = index + 1;
print!("[{}/{}] ", current, total_tests);
print!("{} ", "".bright_blue());
println!("{}", test_suite.name.bold());
let args_display = test_suite.test_args.join(" ");
println!(" {} {}", "args:".dimmed(), args_display.dimmed());
let test_start = Instant::now();
let mut cmd = Command::new("cargo");
cmd.args(test_suite.build_args());
if !verbose {
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::null());
}
let status = cmd
.status()
.context(format!("Failed to execute test: {}", test_suite.name))?;
let duration = test_start.elapsed().as_secs();
let exit_code = status.code().unwrap_or(-1);
let passed = status.success();
if passed {
println!(" {} {}s\n", "".bright_green(), duration);
} else {
println!(
" {} {}s (exit code: {})\n",
"".bright_red(),
duration,
exit_code
);
}
results.push(TestResult {
name: test_suite.name.to_string(),
passed,
});
}
let total_duration = overall_start.elapsed();
print_summary(&results, total_duration);
Ok(results)
}
/// Print test results summary
fn print_summary(results: &[TestResult], total_duration: std::time::Duration) {
let total_tests = results.len();
let passed_tests: Vec<_> = results.iter().filter(|r| r.passed).collect();
let failed_tests: Vec<_> = results.iter().filter(|r| !r.passed).collect();
let minutes = total_duration.as_secs() / 60;
let seconds = total_duration.as_secs() % 60;
println!("{}", "Test Results Summary".bright_cyan().bold());
println!("{} {}m {}s\n", "Total time:".dimmed(), minutes, seconds);
if !passed_tests.is_empty() {
println!(
"{} {}/{}",
"✓ Passed".bright_green().bold(),
passed_tests.len(),
total_tests
);
for result in passed_tests {
println!(" {} {}", "".bright_green(), result.name);
}
println!();
}
if !failed_tests.is_empty() {
println!(
"{} {}/{}",
"✗ Failed".bright_red().bold(),
failed_tests.len(),
total_tests
);
for result in failed_tests {
println!(" {} {}", "".bright_red(), result.name);
}
println!();
}
}