Merge branch 'main' into feature/file-004-rename-and-folders

This commit is contained in:
Jamie Pine
2025-12-25 08:13:40 -08:00
19 changed files with 2202 additions and 25 deletions

BIN
bun.lockb
View File

Binary file not shown.

View File

@@ -657,7 +657,7 @@ impl ChangeHandler for DatabaseAdapter {
async fn handle_new_directory(&self, path: &Path) -> Result<()> {
use crate::domain::addressing::SdPath;
use crate::ops::indexing::{IndexMode, IndexerJob};
use crate::ops::indexing::{IndexMode, IndexerJob, IndexerJobConfig};
let Some(library) = self.context.get_library(self.library_id).await else {
return Ok(());
@@ -678,18 +678,20 @@ impl ChangeHandler for DatabaseAdapter {
IndexMode::Content
};
let indexer_job =
IndexerJob::from_location(self.location_id, SdPath::local(path), index_mode);
let mut config = IndexerJobConfig::new(self.location_id, SdPath::local(path), index_mode);
config.run_in_background = true;
let indexer_job = IndexerJob::new(config);
if let Err(e) = library.jobs().dispatch(indexer_job).await {
tracing::warn!(
"Failed to spawn indexer job for directory {}: {}",
"Failed to spawn background indexer job for directory {}: {}",
path.display(),
e
);
} else {
tracing::debug!(
"Spawned recursive indexer job for directory: {}",
"Spawned background indexer job for directory: {}",
path.display()
);
}

View File

@@ -109,6 +109,9 @@ pub struct IndexerJobConfig {
pub max_depth: Option<u32>,
#[serde(default)]
pub rule_toggles: super::rules::RuleToggles,
/// Whether to run this job in the background (not persisted to database, no UI updates)
#[serde(default)]
pub run_in_background: bool,
}
impl IndexerJobConfig {
@@ -121,6 +124,7 @@ impl IndexerJobConfig {
persistence: IndexPersistence::Persistent,
max_depth: None,
rule_toggles: Default::default(),
run_in_background: false,
}
}
@@ -133,6 +137,7 @@ impl IndexerJobConfig {
persistence: IndexPersistence::Persistent,
max_depth: Some(1),
rule_toggles: Default::default(),
run_in_background: false,
}
}
@@ -149,6 +154,7 @@ impl IndexerJobConfig {
None
},
rule_toggles: Default::default(),
run_in_background: false,
}
}
@@ -196,7 +202,7 @@ impl DynJob for IndexerJob {
}
fn should_persist(&self) -> bool {
!self.config.is_ephemeral()
!self.config.is_ephemeral() && !self.config.run_in_background
}
}
@@ -548,7 +554,10 @@ impl IndexerJob {
None
};
let thumbnail_config = ThumbnailJobConfig::default();
let mut thumbnail_config = ThumbnailJobConfig::default();
// Inherit background flag from the indexer job
thumbnail_config.run_in_background = self.config.run_in_background;
let thumbnail_job = if let Some(uuids) = entry_uuids {
ThumbnailJob::for_entries(uuids, thumbnail_config)
} else {

View File

@@ -117,6 +117,7 @@ impl IndexVerifyAction {
persistence: IndexPersistence::Ephemeral,
max_depth: None,
rule_toggles: Default::default(),
run_in_background: false,
};
// Create the job and set our ephemeral index storage BEFORE dispatching

View File

@@ -30,6 +30,10 @@ pub struct ThumbnailJobConfig {
/// Maximum concurrent thumbnail generations
pub max_concurrent: usize,
/// Whether to run this job in the background (not persisted to database, no UI updates)
#[serde(default)]
pub run_in_background: bool,
}
impl Default for ThumbnailJobConfig {
@@ -39,6 +43,7 @@ impl Default for ThumbnailJobConfig {
regenerate: false,
batch_size: 50,
max_concurrent: 4,
run_in_background: false,
}
}
}
@@ -111,6 +116,10 @@ impl crate::infra::job::traits::DynJob for ThumbnailJob {
fn job_name(&self) -> &'static str {
Self::NAME
}
fn should_persist(&self) -> bool {
!self.config.run_in_background
}
}
/// Output from thumbnail generation job

View File

@@ -23,6 +23,7 @@ use uuid::Uuid;
pub struct IndexingHarnessBuilder {
test_name: String,
watcher_enabled: bool,
daemon_enabled: bool,
}
impl IndexingHarnessBuilder {
@@ -31,6 +32,7 @@ impl IndexingHarnessBuilder {
Self {
test_name: test_name.into(),
watcher_enabled: true, // Enabled by default
daemon_enabled: false, // Disabled by default (only for TypeScript bridge tests)
}
}
@@ -40,6 +42,12 @@ impl IndexingHarnessBuilder {
self
}
/// Enable daemon RPC server for TypeScript bridge tests
pub fn enable_daemon(mut self) -> Self {
self.daemon_enabled = true;
self
}
/// Build the harness
pub async fn build(self) -> anyhow::Result<IndexingHarness> {
// Use home directory for proper filesystem watcher support on macOS
@@ -92,6 +100,39 @@ impl IndexingHarnessBuilder {
.await?
.ok_or_else(|| anyhow::anyhow!("Device not found after registration"))?;
// Wrap core in Arc for shared access
let core = Arc::new(core);
// Start daemon RPC server if enabled (for TypeScript bridge tests)
let daemon_socket_addr = if self.daemon_enabled {
// Find an available port by binding to 0 and getting the actual port
let temp_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let actual_port = temp_listener.local_addr()?.port();
let socket_addr = format!("127.0.0.1:{}", actual_port);
drop(temp_listener); // Release the port
tracing::info!("Starting daemon RPC server on {}", socket_addr);
let core_for_daemon = core.clone();
let socket_addr_clone = socket_addr.clone();
// Spawn daemon server in background
tokio::spawn(async move {
let mut server =
sd_core::infra::daemon::rpc::RpcServer::new(socket_addr_clone, core_for_daemon);
if let Err(e) = server.start().await {
tracing::error!("Daemon RPC server error: {}", e);
}
});
// Wait for server to start accepting connections
tokio::time::sleep(Duration::from_secs(1)).await;
Some(socket_addr)
} else {
None
};
Ok(IndexingHarness {
_test_name: self.test_name,
_test_root: test_root,
@@ -100,6 +141,7 @@ impl IndexingHarnessBuilder {
library,
device_id,
device_db_id: device_record.id,
daemon_socket_addr,
})
}
}
@@ -109,10 +151,11 @@ pub struct IndexingHarness {
_test_name: String,
_test_root: PathBuf,
pub snapshot_dir: PathBuf,
pub core: Core,
pub core: Arc<Core>,
pub library: Arc<sd_core::library::Library>,
pub device_id: Uuid,
pub device_db_id: i32,
daemon_socket_addr: Option<String>,
}
impl IndexingHarness {
@@ -121,6 +164,11 @@ impl IndexingHarness {
&self._test_root
}
/// Get the daemon socket address (only available if daemon is enabled)
pub fn daemon_socket_addr(&self) -> Option<&str> {
self.daemon_socket_addr.as_deref()
}
/// Create a test location directory with files
pub async fn create_test_location(&self, name: &str) -> anyhow::Result<TestLocation> {
let location_dir = self.temp_path().join(name);

View File

@@ -0,0 +1,400 @@
//! TypeScript Integration Test Bridge
//!
//! This test harness sets up a real Spacedrive daemon with indexed locations,
//! then spawns TypeScript tests that interact with it via the ts-client.
//! This enables true end-to-end testing across the Rust backend and TypeScript frontend.
//!
//! ## Architecture
//!
//! 1. Rust test creates daemon + indexed location using IndexingHarnessBuilder
//! 2. Connection info (socket path, library ID) written to JSON file
//! 3. Rust spawns `bun test` with specific TypeScript test file
//! 4. TypeScript test reads connection info, connects to daemon via ts-client
//! 5. TypeScript test performs file operations and cache assertions
//! 6. Rust validates test exit code and cleans up
//!
//! ## Running
//!
//! ```bash
//! cargo test typescript_bridge -- --nocapture
//! ```
mod helpers;
use helpers::*;
use sd_core::location::IndexMode;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::time::Duration;
/// Connection info passed from Rust test harness to TypeScript tests
#[derive(Debug, Serialize, Deserialize)]
struct TestBridgeConfig {
/// TCP socket address for daemon connection (e.g., "127.0.0.1:6969")
socket_addr: String,
/// Library UUID
library_id: String,
/// Location database ID
location_db_id: i32,
/// Physical path to the test location root
location_path: PathBuf,
/// Test data directory (for file operations)
test_data_path: PathBuf,
}
#[tokio::test]
async fn test_typescript_use_normalized_query_with_file_moves() -> anyhow::Result<()> {
// Setup: Create daemon with indexed location
let harness = IndexingHarnessBuilder::new("typescript_bridge_file_moves")
.enable_daemon() // Start RPC server for TypeScript client
.build()
.await?;
let test_location = harness.create_test_location("test_moves").await?;
// Create initial folder structure
test_location.create_dir("folder_a").await?;
test_location.create_dir("folder_b").await?;
test_location
.write_file("folder_a/file1.txt", "Content 1")
.await?;
test_location
.write_file("folder_a/file2.rs", "fn main() {}")
.await?;
test_location
.write_file("folder_b/file3.md", "# Docs")
.await?;
// Index the location
let location = test_location
.index("TypeScript Test Location", IndexMode::Shallow)
.await?;
// Wait for indexing to complete
tokio::time::sleep(Duration::from_secs(1)).await;
// Get daemon socket address
let socket_addr = harness
.daemon_socket_addr()
.expect("Daemon should be enabled")
.to_string();
// Prepare bridge config
let bridge_config = TestBridgeConfig {
socket_addr: socket_addr.clone(),
library_id: harness.library.id().to_string(),
location_db_id: location.db_id,
location_path: test_location.path().to_path_buf(),
test_data_path: harness.temp_path().to_path_buf(),
};
// Write config to temp file for TypeScript to read
let config_path = harness.temp_path().join("typescript_bridge_config.json");
let config_json = serde_json::to_string_pretty(&bridge_config)?;
tokio::fs::write(&config_path, config_json).await?;
tracing::info!("Bridge config written to: {}", config_path.display());
tracing::info!("Socket address: {}", socket_addr);
tracing::info!("Library ID: {}", bridge_config.library_id);
// Spawn TypeScript test process
let ts_test_file = "packages/ts-client/tests/integration/useNormalizedQuery.test.ts";
let workspace_root = std::env::current_dir()?.parent().unwrap().to_path_buf();
let ts_test_path = workspace_root.join(ts_test_file);
let bun_config = workspace_root.join("packages/ts-client/tests/integration/bunfig.toml");
eprintln!("\n=== TypeScript Bridge Test ===");
eprintln!("Workspace root: {}", workspace_root.display());
eprintln!("Test file: {}", ts_test_path.display());
eprintln!("Bun config: {}", bun_config.display());
eprintln!("Config path: {}", config_path.display());
eprintln!("Socket address: {}", socket_addr);
eprintln!("Library ID: {}", bridge_config.library_id);
eprintln!("==============================\n");
// Check if test file exists
if !ts_test_path.exists() {
anyhow::bail!("TypeScript test file not found: {}", ts_test_path.display());
}
let output = tokio::process::Command::new("bun")
.arg("test")
.arg("--config")
.arg(&bun_config)
.arg(&ts_test_path)
.env("BRIDGE_CONFIG_PATH", config_path.to_str().unwrap())
.env("RUST_LOG", "debug")
.current_dir(&workspace_root)
.output()
.await?;
// Always print TypeScript output to stderr for visibility
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !stdout.is_empty() {
eprintln!("\n=== TypeScript stdout ===\n{}\n", stdout);
}
if !stderr.is_empty() {
eprintln!("\n=== TypeScript stderr ===\n{}\n", stderr);
}
// Verify TypeScript test passed
if !output.status.success() {
anyhow::bail!(
"TypeScript test failed with exit code: {:?}",
output.status.code()
);
}
tracing::info!("TypeScript test passed! ✓");
// Cleanup
harness.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn test_typescript_use_normalized_query_with_folder_renames() -> anyhow::Result<()> {
// Setup: Create daemon with indexed location
let harness = IndexingHarnessBuilder::new("typescript_bridge_folder_renames")
.enable_daemon() // Start RPC server for TypeScript client
.build()
.await?;
let test_location = harness.create_test_location("test_renames").await?;
// Create initial folder structure
test_location.create_dir("original_folder").await?;
test_location
.write_file("original_folder/file1.txt", "Content 1")
.await?;
test_location
.write_file("original_folder/file2.rs", "fn main() {}")
.await?;
test_location
.write_file("original_folder/nested/file3.md", "# Docs")
.await?;
// Index the location
let location = test_location
.index("TypeScript Test Location", IndexMode::Shallow)
.await?;
// Wait for indexing to complete
tokio::time::sleep(Duration::from_secs(1)).await;
// Get daemon socket address
let socket_addr = harness
.daemon_socket_addr()
.expect("Daemon should be enabled")
.to_string();
// Prepare bridge config
let bridge_config = TestBridgeConfig {
socket_addr: socket_addr.clone(),
library_id: harness.library.id().to_string(),
location_db_id: location.db_id,
location_path: test_location.path().to_path_buf(),
test_data_path: harness.temp_path().to_path_buf(),
};
// Write config to temp file
let config_path = harness.temp_path().join("typescript_bridge_config.json");
let config_json = serde_json::to_string_pretty(&bridge_config)?;
tokio::fs::write(&config_path, config_json).await?;
tracing::info!("Bridge config written to: {}", config_path.display());
// Spawn TypeScript test process
let ts_test_file =
"packages/ts-client/tests/integration/useNormalizedQuery.folder-rename.test.ts";
let workspace_root = std::env::current_dir()?.parent().unwrap().to_path_buf();
let ts_test_path = workspace_root.join(ts_test_file);
let bun_config = workspace_root.join("packages/ts-client/tests/integration/bunfig.toml");
eprintln!("\n=== TypeScript Bridge Test ===");
eprintln!("Workspace root: {}", workspace_root.display());
eprintln!("Test file: {}", ts_test_path.display());
eprintln!("Bun config: {}", bun_config.display());
eprintln!("Config path: {}", config_path.display());
eprintln!("Socket address: {}", socket_addr);
eprintln!("Library ID: {}", bridge_config.library_id);
eprintln!("==============================\n");
// Check if test file exists
if !ts_test_path.exists() {
anyhow::bail!("TypeScript test file not found: {}", ts_test_path.display());
}
let output = tokio::process::Command::new("bun")
.arg("test")
.arg("--config")
.arg(&bun_config)
.arg(&ts_test_path)
.env("BRIDGE_CONFIG_PATH", config_path.to_str().unwrap())
.env("RUST_LOG", "debug")
.current_dir(&workspace_root)
.output()
.await?;
// Always print TypeScript output to stderr for visibility
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !stdout.is_empty() {
eprintln!("\n=== TypeScript stdout ===\n{}\n", stdout);
}
if !stderr.is_empty() {
eprintln!("\n=== TypeScript stderr ===\n{}\n", stderr);
}
// Verify test passed
if !output.status.success() {
anyhow::bail!(
"TypeScript test failed with exit code: {:?}",
output.status.code()
);
}
tracing::info!("TypeScript test passed! ✓");
harness.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn test_typescript_use_normalized_query_with_bulk_moves() -> anyhow::Result<()> {
// Setup: Create daemon with indexed location
let harness = IndexingHarnessBuilder::new("typescript_bridge_bulk_moves")
.enable_daemon() // Start RPC server for TypeScript client
.build()
.await?;
let test_location = harness.create_test_location("test_bulk").await?;
// Create subfolder with 20 files
// Mix of text files and files that will get content identity
test_location.create_dir("bulk_test").await?;
for i in 1..=20 {
if i <= 10 {
// First 10: simple text files (likely Physical paths)
test_location
.write_file(
&format!("bulk_test/file{:02}.txt", i),
&format!("Content of file {}", i),
)
.await?;
} else {
// Last 10: larger files more likely to get content identity
// Create files with more content to trigger content identification
let content = format!(
"# File {}\n{}",
i,
"Lorem ipsum dolor sit amet. ".repeat(100)
);
test_location
.write_file(&format!("bulk_test/file{:02}.md", i), &content)
.await?;
}
}
// Also create a couple files in root to verify they're not affected
test_location
.write_file("root_file1.md", "# Root file")
.await?;
test_location
.write_file("root_file2.rs", "fn main() {}")
.await?;
// Index the location with Content mode to enable content identification
// Shallow mode only indexes metadata; Content mode computes hashes and creates content identity
// This is critical for testing the cache update bug with content-addressed files
tracing::info!("Starting indexing with Content mode (includes content identification)...");
let location = test_location
.index("TypeScript Bulk Test Location", IndexMode::Content)
.await?;
tracing::info!("Indexing completed, waiting for content identification to settle...");
// Wait extra time for content identification and event processing
tokio::time::sleep(Duration::from_secs(5)).await;
tracing::info!("Ready to start TypeScript test");
// Get daemon socket address
let socket_addr = harness
.daemon_socket_addr()
.expect("Daemon should be enabled")
.to_string();
// Prepare bridge config
let bridge_config = TestBridgeConfig {
socket_addr: socket_addr.clone(),
library_id: harness.library.id().to_string(),
location_db_id: location.db_id,
location_path: test_location.path().to_path_buf(),
test_data_path: harness.temp_path().to_path_buf(),
};
// Write config to temp file
let config_path = harness.temp_path().join("typescript_bridge_config.json");
let config_json = serde_json::to_string_pretty(&bridge_config)?;
tokio::fs::write(&config_path, config_json).await?;
tracing::info!("Bridge config written to: {}", config_path.display());
// Spawn TypeScript test process - use dedicated bulk moves test file
let ts_test_file = "packages/ts-client/tests/integration/useNormalizedQuery.bulk-moves.test.ts";
let workspace_root = std::env::current_dir()?.parent().unwrap().to_path_buf();
let ts_test_path = workspace_root.join(ts_test_file);
let bun_config = workspace_root.join("packages/ts-client/tests/integration/bunfig.toml");
eprintln!("\n=== TypeScript Bridge Test (Bulk Moves) ===");
eprintln!("Workspace root: {}", workspace_root.display());
eprintln!("Test file: {}", ts_test_path.display());
eprintln!("Bun config: {}", bun_config.display());
eprintln!("Config path: {}", config_path.display());
eprintln!("Socket address: {}", socket_addr);
eprintln!("Library ID: {}", bridge_config.library_id);
eprintln!("==============================\n");
// Check if test file exists
if !ts_test_path.exists() {
anyhow::bail!("TypeScript test file not found: {}", ts_test_path.display());
}
let output = tokio::process::Command::new("bun")
.arg("test")
.arg("--config")
.arg(&bun_config)
.arg(&ts_test_path)
.env("BRIDGE_CONFIG_PATH", config_path.to_str().unwrap())
.env("RUST_LOG", "debug")
.current_dir(&workspace_root)
.output()
.await?;
// Always print TypeScript output to stderr for visibility
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !stdout.is_empty() {
eprintln!("\n=== TypeScript stdout ===\n{}\n", stdout);
}
if !stderr.is_empty() {
eprintln!("\n=== TypeScript stderr ===\n{}\n", stderr);
}
// Verify test passed
if !output.status.success() {
anyhow::bail!(
"TypeScript test failed with exit code: {:?}",
output.status.code()
);
}
tracing::info!("TypeScript bulk move test passed! ✓");
harness.shutdown().await?;
Ok(())
}

View File

@@ -593,6 +593,288 @@ Common debugging approaches:
- [ ] Handle process coordination properly
- [ ] Set reasonable timeouts
## TypeScript Integration Testing
Spacedrive provides a bridge infrastructure for running TypeScript tests against a real Rust daemon. This enables true end-to-end testing across the Rust backend and TypeScript frontend, verifying that cache updates, WebSocket events, and React hooks work correctly with real data.
### Architecture
The TypeScript bridge test pattern works as follows:
1. **Rust test** creates a daemon with indexed locations using `IndexingHarnessBuilder`
2. **Connection info** (TCP socket address, library ID, paths) written to JSON config file
3. **Rust spawns** `bun test` with specific TypeScript test file
4. **TypeScript test** reads config, connects to daemon via `SpacedriveClient.fromTcpSocket()`
5. **TypeScript test** performs file operations and validates cache updates via React hooks
6. **Rust validates** test exit code and cleans up
This pattern tests the entire stack: Rust daemon → RPC transport → TypeScript client → React hooks → cache updates.
### Writing Bridge Tests
#### Rust Side
Create a test in `core/tests/` that spawns the daemon and TypeScript test:
```rust
#[tokio::test]
async fn test_typescript_cache_updates() -> anyhow::Result<()> {
// Create daemon with RPC server enabled
let harness = IndexingHarnessBuilder::new("typescript_bridge_test")
.enable_daemon() // Start RPC server for TypeScript client
.build()
.await?;
// Create test location with files
let test_location = harness.create_test_location("test_files").await?;
test_location.create_dir("folder_a").await?;
test_location.write_file("folder_a/file1.txt", "Content").await?;
// Index the location
let location = test_location
.index("Test Location", IndexMode::Shallow)
.await?;
// Get daemon socket address
let socket_addr = harness
.daemon_socket_addr()
.expect("Daemon should be enabled")
.to_string();
// Prepare bridge config for TypeScript
let bridge_config = TestBridgeConfig {
socket_addr,
library_id: harness.library.id().to_string(),
location_db_id: location.db_id,
location_path: test_location.path().to_path_buf(),
test_data_path: harness.temp_path().to_path_buf(),
};
// Write config to temp file
let config_path = harness.temp_path().join("bridge_config.json");
tokio::fs::write(&config_path, serde_json::to_string_pretty(&bridge_config)?).await?;
// Spawn TypeScript test
let ts_test_file = "packages/ts-client/tests/integration/mytest.test.ts";
let workspace_root = std::env::current_dir()?.parent().unwrap().to_path_buf();
let output = tokio::process::Command::new("bun")
.arg("test")
.arg(workspace_root.join(ts_test_file))
.env("BRIDGE_CONFIG_PATH", config_path.to_str().unwrap())
.current_dir(&workspace_root)
.output()
.await?;
// Verify TypeScript test passed
if !output.status.success() {
anyhow::bail!("TypeScript test failed: {:?}", output.status.code());
}
harness.shutdown().await?;
Ok(())
}
```
<Note>
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
Create a test in `packages/ts-client/tests/integration/`:
```typescript
import { describe, test, expect, beforeAll } from "bun:test";
import { readFile } from "fs/promises";
import { SpacedriveClient } from "../../src/client";
import { renderHook, waitFor } from "@testing-library/react";
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;
}
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);
// 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);
// 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();
});
// Perform file operation
await rename(oldPath, newPath);
// 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" })
);
});
});
```
### TCP Transport
TypeScript tests connect to the daemon via TCP socket using `TcpSocketTransport`. This transport is designed for Bun/Node.js environments and enables testing outside the browser.
```typescript
// Automatic with factory method
const client = SpacedriveClient.fromTcpSocket("127.0.0.1:6969");
// Manual construction
import { TcpSocketTransport } from "@sd/ts-client/transports";
const transport = new TcpSocketTransport("127.0.0.1:6969");
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
- Works in both Bun and Node.js runtimes
### Testing Cache Updates
The primary use case for bridge tests is verifying that `useNormalizedQuery` cache updates work correctly when the daemon emits `ResourceChanged` or `ResourceChangedBatch` events.
**Key patterns:**
1. **Enable debug logging** with `debug: true` in `useNormalizedQuery` options
2. **Wait for watcher delays** (500ms buffer + processing time, typically 2-8 seconds)
3. **Collect events** by wrapping the subscription manager to log all received events
4. **Verify cache state** using React Testing Library's `waitFor` and assertions
```typescript
// Enable debug logging
const { result } = renderHook(
() => 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);
};
```
### Running Bridge Tests
```bash
# Run all TypeScript bridge tests
cargo test --package sd-core --test typescript_bridge_test -- --nocapture
# Run specific bridge test
cargo test test_typescript_use_normalized_query_with_file_moves -- --nocapture
# Run only the TypeScript side (requires manual daemon setup)
cd packages/ts-client
BRIDGE_CONFIG_PATH=/path/to/config.json bun test tests/integration/mytest.test.ts
```
<Tip>
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
### 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
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()`
- **Wrong library**: Check that `client.setCurrentLibrary()` uses correct ID from config
## Examples
For complete examples, refer to:
@@ -607,4 +889,10 @@ For complete examples, refer to:
**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/file_transfer_test.rs` - Cross-device file 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

View File

@@ -41,6 +41,7 @@ export function GridView() {
resourceType: "file",
enabled: !!currentPath && !isVirtualView,
pathScope: currentPath ?? undefined,
// debug: true,
});
const files = isVirtualView
@@ -64,7 +65,11 @@ export function GridView() {
if (!shouldVirtualize) {
return (
<div ref={gridContainerRef} className="h-full overflow-auto" onClick={handleContainerClick}>
<div
ref={gridContainerRef}
className="h-full overflow-auto"
onClick={handleContainerClick}
>
<DragSelect files={files} scrollRef={gridContainerRef}>
<div
className="grid p-3 min-h-full"
@@ -306,7 +311,9 @@ function VirtualizedGrid({
fileIndex={fileIndex}
allFiles={files}
selected={isSelected(file.id)}
focused={fileIndex === focusedIndex}
focused={
fileIndex === focusedIndex
}
selectedFiles={selectedFiles}
selectFile={selectFile}
/>

View File

@@ -45,12 +45,14 @@
}
},
"devDependencies": {
"@happy-dom/global-registrator": "^15.11.0",
"@tanstack/react-query": "^5.62.0",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
"@types/jest": "^29.0.0",
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"happy-dom": "^15.11.0",
"jest": "^29.0.0",
"jest-environment-jsdom": "^29.0.0",
"jsdom": "^27.2.0",

View File

@@ -1,5 +1,5 @@
import type { Transport } from "./transport";
import { UnixSocketTransport, TauriTransport } from "./transport";
import { UnixSocketTransport, TcpSocketTransport, TauriTransport } from "./transport";
import type { Event } from "./generated/types";
import { DEFAULT_EVENT_SUBSCRIPTION } from "./event-filter";
import { SubscriptionManager } from "./subscriptionManager";
@@ -67,6 +67,14 @@ export class SpacedriveClient extends SimpleEventEmitter {
return new SpacedriveClient(new UnixSocketTransport(socketPath));
}
/**
* Create client for Bun/Node.js using TCP socket
* @param socketAddr - TCP address (e.g., "127.0.0.1:6969")
*/
static fromTcpSocket(socketAddr: string): SpacedriveClient {
return new SpacedriveClient(new TcpSocketTransport(socketAddr));
}
/**
* Create client for Tauri using IPC
*/

View File

@@ -590,17 +590,25 @@ function updateArrayCache(
for (const resource of newResources) {
if (!seenIds.has(resource.id) && resource.sd_path?.Content) {
// Try to find existing Physical entry by matching alternate_paths
const physicalPath = resource.alternate_paths?.find((p: any) => p.Physical)?.Physical?.path;
const physicalPath = resource.alternate_paths?.find(
(p: any) => p.Physical,
)?.Physical?.path;
if (physicalPath) {
const existingIndex = newData.findIndex((item: any) => {
const itemPath = item.sd_path?.Physical?.path ||
item.alternate_paths?.find((p: any) => p.Physical)?.Physical?.path;
const itemPath =
item.sd_path?.Physical?.path ||
item.alternate_paths?.find((p: any) => p.Physical)
?.Physical?.path;
return itemPath === physicalPath;
});
if (existingIndex !== -1) {
// Merge Content entry into existing Physical entry
newData[existingIndex] = safeMerge(newData[existingIndex], resource, noMergeFields);
newData[existingIndex] = safeMerge(
newData[existingIndex],
resource,
noMergeFields,
);
seenIds.add(resource.id);
}
}
@@ -610,10 +618,19 @@ function updateArrayCache(
// Append new items (excluding Content paths that didn't match an existing entry)
for (const resource of newResources) {
if (!seenIds.has(resource.id)) {
// Skip resources with Content paths - they represent alternate instances
// and should only update existing entries (e.g., thumbnail generation)
// For Content paths: only add if they don't belong to an existing Physical entry
// Content paths without matching Physical entries are either:
// 1. Files moved into this directory (have alternate_paths but no match) → ADD
// 2. Metadata updates for files elsewhere (no relevant alternate_paths) → SKIP
if (resource.sd_path?.Content) {
continue;
// Skip if no alternate_paths (pure metadata update)
if (
!resource.alternate_paths ||
resource.alternate_paths.length === 0
) {
continue;
}
// Otherwise, this is a real file that belongs here - add it
}
newData.push(resource);
}
@@ -660,17 +677,25 @@ function updateWrappedCache(
for (const resource of newResources) {
if (!seenIds.has(resource.id) && resource.sd_path?.Content) {
// Try to find existing Physical entry by matching alternate_paths
const physicalPath = resource.alternate_paths?.find((p: any) => p.Physical)?.Physical?.path;
const physicalPath = resource.alternate_paths?.find(
(p: any) => p.Physical,
)?.Physical?.path;
if (physicalPath) {
const existingIndex = array.findIndex((item: any) => {
const itemPath = item.sd_path?.Physical?.path ||
item.alternate_paths?.find((p: any) => p.Physical)?.Physical?.path;
const itemPath =
item.sd_path?.Physical?.path ||
item.alternate_paths?.find((p: any) => p.Physical)
?.Physical?.path;
return itemPath === physicalPath;
});
if (existingIndex !== -1) {
// Merge Content entry into existing Physical entry
array[existingIndex] = safeMerge(array[existingIndex], resource, noMergeFields);
array[existingIndex] = safeMerge(
array[existingIndex],
resource,
noMergeFields,
);
seenIds.add(resource.id);
}
}
@@ -680,9 +705,19 @@ function updateWrappedCache(
// Append new items (excluding Content paths that didn't match an existing entry)
for (const resource of newResources) {
if (!seenIds.has(resource.id)) {
// Skip resources with Content paths - they represent alternate instances
// For Content paths: only add if they don't belong to an existing Physical entry
// Content paths without matching Physical entries are either:
// 1. Files moved into this directory (have alternate_paths but no match) → ADD
// 2. Metadata updates for files elsewhere (no relevant alternate_paths) → SKIP
if (resource.sd_path?.Content) {
continue;
// Skip if no alternate_paths (pure metadata update)
if (
!resource.alternate_paths ||
resource.alternate_paths.length === 0
) {
continue;
}
// Otherwise, this is a real file that belongs here - add it
}
// Check if resource already exists in the array (by ID)

View File

@@ -86,6 +86,136 @@ export class TauriTransport implements Transport {
}
}
/**
* TCP socket transport for Bun/Node environments
* Connects to daemon via TCP (e.g., "127.0.0.1:6969")
*/
export class TcpSocketTransport implements Transport {
constructor(private socketAddr: string) {}
async sendRequest(request: any): Promise<any> {
// Parse socket address (e.g., "127.0.0.1:6969")
const [hostname, portStr] = this.socketAddr.split(":");
const port = parseInt(portStr, 10);
return new Promise((resolve, reject) => {
let buffer = "";
// @ts-ignore - Bun global
Bun.connect({
hostname,
port,
socket: {
data(socket: any, data: any) {
buffer += new TextDecoder().decode(data);
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
socket.end();
try {
resolve(JSON.parse(line));
} catch (e) {
reject(e);
}
}
},
open(socket: any) {
const requestLine = JSON.stringify(request) + "\n";
socket.write(requestLine);
},
error(socket: any, error: Error) {
reject(error);
},
close(socket: any) {
if (buffer && !buffer.includes("\n")) {
reject(new Error("Connection closed without complete response"));
}
},
},
});
});
}
async subscribe(
callback: (event: any) => void,
options?: SubscriptionOptions,
): Promise<() => void> {
// Parse socket address
const [hostname, portStr] = this.socketAddr.split(":");
const port = parseInt(portStr, 10);
let socketInstance: any = null;
let buffer = "";
// Subscribe to relevant events
const subscribeRequest = {
Subscribe: {
event_types: options?.event_types ?? DEFAULT_EVENT_SUBSCRIPTION,
filter: options?.filter ?? null,
},
};
// @ts-ignore - Bun global
socketInstance = await Bun.connect({
hostname,
port,
socket: {
data(socket: any, data: any) {
buffer += new TextDecoder().decode(data);
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line) {
try {
const response = JSON.parse(line);
// Handle DaemonResponse variants
if (response === "Subscribed" || response.Subscribed !== undefined) {
// Subscription acknowledgment, don't forward
} else if (response.Event) {
// Event message, forward to callback
callback(response.Event);
} else if (response.LogMessage) {
// Log message, forward to callback
callback(response.LogMessage);
} else {
console.warn(
"[TcpSocketTransport] Unexpected response:",
response,
);
}
} catch (e) {
console.error("[TcpSocketTransport] Parse error:", e);
}
}
}
},
open(socket: any) {
// Send subscription request once connected
socket.write(JSON.stringify(subscribeRequest) + "\n");
},
error(socket: any, error: Error) {
console.error("[TcpSocketTransport] Socket error:", error);
},
close(socket: any) {
console.log("[TcpSocketTransport] Connection closed");
},
},
});
// Return unsubscribe function
return () => {
if (socketInstance) {
socketInstance.end();
}
};
}
}
/**
* Unix socket transport for Bun/Node environments
* Note: This won't work in browser, use TauriTransport instead

View File

@@ -0,0 +1,279 @@
# TypeScript Integration Tests with Rust Bridge
This directory contains end-to-end integration tests that bridge Rust and TypeScript, enabling real testing of TypeScript React hooks (`useNormalizedQuery`) against an actual Spacedrive daemon with indexed files.
## Architecture
The testing bridge works as follows:
1. **Rust Test Harness** (`core/tests/typescript_bridge_test.rs`)
- Sets up a real Spacedrive daemon with RPC server
- Indexes a test location with files
- Writes connection config to JSON file
- Spawns `bun test` to run TypeScript tests
- Validates TypeScript test exit code
2. **Bridge Configuration** (JSON passed via `BRIDGE_CONFIG_PATH`)
- `socket_addr`: TCP address of daemon (e.g., "127.0.0.1:41234")
- `library_id`: UUID of test library
- `location_db_id`: Database ID of indexed location
- `location_path`: Physical filesystem path to test location
- `test_data_path`: Temporary directory for test data
3. **TypeScript Test** (e.g., `useNormalizedQuery.test.ts`)
- Reads bridge config from environment variable
- Connects to daemon via `TcpSocketTransport`
- Uses React Testing Library to test hooks
- Performs filesystem operations (move, rename)
- Asserts cache updates correctly via WebSocket events
## Running the Tests
### Setup (First Time Only)
Make sure dependencies are installed:
```bash
# From workspace root
bun install
```
This installs:
- `happy-dom` + `@happy-dom/global-registrator` - Fast, lightweight DOM environment (5-10x faster than jsdom)
- `@testing-library/react` - React hook testing utilities
- `@tanstack/react-query` - Query cache management
- All other required dependencies
### Full End-to-End Test (Rust → TypeScript)
```bash
cd core
cargo test --package sd-core --test typescript_bridge_test -- --nocapture
```
This will:
- Build Rust code
- Start daemon with test data
- Run TypeScript tests via Bun
- Display output from both Rust and TypeScript
- Fail if either side fails
### TypeScript Tests Only (Manual)
If you need to debug the TypeScript side independently:
```bash
# Terminal 1: Start a daemon manually
cd core
cargo run --bin sd-daemon
# Terminal 2: Run TypeScript tests with manual config
export BRIDGE_CONFIG_PATH=/path/to/bridge/config.json
bun test packages/ts-client/tests/integration/useNormalizedQuery.test.ts
```
## Test Scenarios
### File Move Test (`useNormalizedQuery.test.ts`)
Tests that `useNormalizedQuery` correctly updates cache when files move between folders:
1. Query `folder_a` directory listing
2. Query `folder_b` directory listing
3. Move `file1.txt` from `folder_a` to `folder_b` (filesystem operation)
4. Wait for watcher to detect change
5. Assert `file1` removed from `folder_a` cache
6. Assert `file1` added to `folder_b` cache with same UUID (move detection)
### Folder Rename Test (`useNormalizedQuery.folder-rename.test.ts`)
Tests that `useNormalizedQuery` correctly updates cache when folders are renamed:
1. Query root directory listing (contains `original_folder`)
2. Rename `original_folder` to `renamed_folder` (filesystem operation)
3. Wait for watcher to detect change
4. Assert `original_folder` removed from cache
5. Assert `renamed_folder` appears in cache with same UUID (identity preserved)
## Test Environment Setup
The integration tests use a DOM environment provided by **Happy DOM** (not jsdom) to support React Testing Library. Happy DOM is:
- **5-10x faster** than jsdom for React hook tests
- **Lighter weight** - optimized specifically for testing
- **Easier to configure** - one-liner setup with automatic global registration
### How It Works
- **`setup.ts`** - Imports `@happy-dom/global-registrator` and registers DOM globals
```typescript
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register();
```
- **Test files** - Import `./setup` as the first line to initialize DOM before React imports
- **Cleanup** - Each test calls `cleanup()` from `@testing-library/react` after tests
This provides `document`, `window`, `HTMLElement`, and all other browser globals needed by React hooks.
## How It Works
### Daemon Setup (Rust Side)
The `IndexingHarnessBuilder` has been extended with `.enable_daemon()`:
```rust
let harness = IndexingHarnessBuilder::new("test_name")
.enable_daemon() // Starts RPC server on random port
.build()
.await?;
let socket_addr = harness.daemon_socket_addr().unwrap();
// socket_addr = "127.0.0.1:41234" (random available port)
```
The daemon runs in a background task, sharing the same `Core` instance with the test harness.
### TypeScript Connection
```typescript
import { SpacedriveClient } from "@sd/ts-client";
// Read config from Rust bridge
const bridgeConfig = JSON.parse(await readFile(process.env.BRIDGE_CONFIG_PATH));
// Connect via TCP
const client = SpacedriveClient.fromTcpSocket(bridgeConfig.socket_addr);
// Set library context
await client.setLibrary(bridgeConfig.library_id);
// Now use hooks normally!
const { data } = useNormalizedQuery({
wireMethod: "query:files.directory_listing",
input: { path: { Physical: { path: "/some/path" } } },
resourceType: "file",
// ...
});
```
### Event Flow
```
Filesystem Change (rename/move)
Watcher Detection (Rust)
Indexing Update (Database)
ResourceChanged Event (WebSocket)
SubscriptionManager Filters (TypeScript)
useNormalizedQuery Event Handler
Cache Update (TanStack Query)
React Re-render (Hooks)
```
## Benefits
1. **True End-to-End Testing**: Tests the entire stack from filesystem to React hooks
2. **Real Watcher Integration**: Tests actual file system watcher behavior, not mocks
3. **Cross-Language Validation**: Ensures Rust and TypeScript stay in sync
4. **Regression Detection**: Catches breaking changes in event emission, caching, or filtering
5. **Documentation by Example**: Tests serve as living examples of how the system works
## Adding New Tests
1. Create a new test in `typescript_bridge_test.rs`:
```rust
#[tokio::test]
async fn test_typescript_my_new_feature() -> anyhow::Result<()> {
let harness = IndexingHarnessBuilder::new("my_test")
.enable_daemon()
.build()
.await?;
// Set up test data
let test_location = harness.create_test_location("test").await?;
test_location.write_file("test.txt", "content").await?;
let location = test_location.index("Test", IndexMode::Shallow).await?;
// Write bridge config (see other tests for example)
let bridge_config = TestBridgeConfig { /* ... */ };
// ...
// Spawn TypeScript test
let output = tokio::process::Command::new("bun")
.arg("test")
.arg("packages/ts-client/tests/integration/my-feature.test.ts")
.env("BRIDGE_CONFIG_PATH", config_path.to_str().unwrap())
.output()
.await?;
assert!(output.status.success());
Ok(())
}
```
2. Create corresponding TypeScript test:
```typescript
import { SpacedriveClient } from "@sd/ts-client";
import { renderHook } from "@testing-library/react";
test("my feature works", async () => {
const bridgeConfig = JSON.parse(/* read from env */);
const client = SpacedriveClient.fromTcpSocket(bridgeConfig.socket_addr);
// Test your feature!
const { result } = renderHook(() => useMyFeature(...));
// ...
});
```
## Debugging
### Enable Debug Logging
```bash
# Rust side
RUST_LOG=debug cargo test typescript_bridge -- --nocapture
# TypeScript side (in test file)
const query = useNormalizedQuery({
// ...
debug: true, // Enables console.log for event processing
});
```
### Common Issues
**"Connection refused"**: Daemon didn't start or took too long. Increase sleep duration in Rust test.
**"No events received"**: Watcher may be disabled or buffering. Check:
- Watcher is enabled in harness (default)
- Wait time is sufficient (8+ seconds for folder renames due to buffering)
- Path is correct and indexing completed
**"Cache not updating"**: Check:
- Event subscription filter matches query scope
- Resource type matches ("file", "location", etc.)
- pathScope is set correctly for file queries
## Future Enhancements
- [ ] Add tests for batch operations
- [ ] Test error handling and retry logic
- [ ] Test network interruption scenarios
- [ ] Add performance benchmarks
- [ ] Test concurrent operations
- [ ] Add tests for content identification events
- [ ] Test tag/label updates

View File

@@ -0,0 +1,4 @@
# Bun test configuration for integration tests
# Note: setup.ts is imported directly in test files rather than preloaded
[test]

View File

@@ -0,0 +1,29 @@
/**
* Test setup for Bun integration tests
* Provides a DOM environment for React Testing Library using Happy DOM
*
* Happy DOM is faster and lighter than jsdom, optimized for testing.
* This one-liner registers all DOM globals automatically.
*/
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register();
// Suppress React act() warnings for async event-driven state updates
// In integration tests, daemon events trigger React state updates asynchronously,
// which is expected behavior and doesn't need act() wrapping
const originalError = console.error;
console.error = (...args: any[]) => {
const message = args[0];
if (
typeof message === "string" &&
message.includes(
"An update to TestComponent inside a test was not wrapped in act",
)
) {
// Suppress act() warnings - they're expected for real-time event updates
return;
}
originalError.apply(console, args);
};

View File

@@ -0,0 +1,433 @@
import "./setup"; // Ensure DOM environment is loaded first
import {
describe,
test,
expect,
beforeAll,
afterAll,
afterEach,
} from "bun:test";
import { readFile } from "fs/promises";
import { rename } from "fs/promises";
import { join } from "path";
import { hostname } from "os";
import { SpacedriveClient } from "../../src/client";
import { SpacedriveProvider } from "../../src/hooks/useClient";
import { useNormalizedQuery } from "../../src/hooks/useNormalizedQuery";
import { renderHook, waitFor, act, cleanup } from "@testing-library/react";
import React from "react";
// Bridge config type matching Rust TestBridgeConfig
interface BridgeConfig {
socket_addr: string;
library_id: string;
location_db_id: number;
location_path: string;
test_data_path: string;
}
describe("useNormalizedQuery - Bulk Moves Integration", () => {
let bridgeConfig: BridgeConfig;
let client: SpacedriveClient;
const allEventsReceived: any[] = []; // Collect all events for debugging
beforeAll(async () => {
// Read bridge config from path provided by Rust test
const configPath = process.env.BRIDGE_CONFIG_PATH;
if (!configPath) {
throw new Error("BRIDGE_CONFIG_PATH environment variable not set");
}
console.log(`[TS] Reading bridge config from: ${configPath}`);
const configJson = await readFile(configPath, "utf-8");
bridgeConfig = JSON.parse(configJson);
console.log(`[TS] Bridge config:`, bridgeConfig);
// Connect to daemon via TCP socket
client = SpacedriveClient.fromTcpSocket(bridgeConfig.socket_addr);
console.log(`[TS] Connected to daemon`);
// Set library context
client.setCurrentLibrary(bridgeConfig.library_id);
console.log(`[TS] Library set to: ${bridgeConfig.library_id}`);
// Hook into the subscription manager to collect all events
const originalCreateSubscription = (client as any).subscriptionManager
.createSubscription;
(client as any).subscriptionManager.createSubscription = function (
filter: any,
callback: any,
) {
const wrappedCallback = (event: any) => {
allEventsReceived.push({
timestamp: new Date().toISOString(),
filter,
event,
});
console.log(
`[TS] 🔔 Event received:`,
JSON.stringify(event, null, 2),
);
callback(event);
};
return originalCreateSubscription.call(
this,
filter,
wrappedCallback,
);
};
});
afterAll(async () => {
// Log all events at the end for debugging
console.log(
`[TS] ===== ALL EVENTS RECEIVED (${allEventsReceived.length}) =====`,
);
allEventsReceived.forEach((item, idx) => {
console.log(`[TS] Event ${idx + 1} at ${item.timestamp}:`);
console.log(`[TS] Filter:`, JSON.stringify(item.filter, null, 2));
console.log(`[TS] Event:`, JSON.stringify(item.event, null, 2));
});
console.log(`[TS] ===== END OF EVENTS =====`);
// No explicit disconnect needed for stateless transports
});
afterEach(cleanup); // Clean up React Testing Library after each test
test("should update cache when moving 20 files from subfolder to root", async () => {
const rootPath = bridgeConfig.location_path;
const subfolderPath = join(rootPath, "bulk_test");
// Get device slug from hostname
const deviceSlug = hostname().toLowerCase().replace(/\s+/g, "-");
// Create wrapper for React hooks with SpacedriveProvider
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(SpacedriveProvider, { client }, children);
// Query root directory listing
const { result: rootResult } = renderHook(
() =>
useNormalizedQuery({
wireMethod: "query:files.directory_listing",
input: {
path: {
Physical: {
device_slug: deviceSlug,
path: rootPath,
},
},
sort_by: "name",
},
resourceType: "file",
pathScope: {
Physical: {
device_slug: deviceSlug,
path: rootPath,
},
},
includeDescendants: false,
debug: true,
}),
{ wrapper },
);
// Query subfolder listing
const { result: subfolderResult } = renderHook(
() =>
useNormalizedQuery({
wireMethod: "query:files.directory_listing",
input: {
path: {
Physical: {
device_slug: deviceSlug,
path: subfolderPath,
},
},
sort_by: "name",
},
resourceType: "file",
pathScope: {
Physical: {
device_slug: deviceSlug,
path: subfolderPath,
},
},
includeDescendants: false,
debug: true,
}),
{ wrapper },
);
// Wait for initial data
await waitFor(() => {
expect(rootResult.current.data).toBeDefined();
expect(subfolderResult.current.data).toBeDefined();
});
const initialRootData = rootResult.current.data as {
files: any[];
};
const initialSubfolderData = subfolderResult.current.data as {
files: any[];
};
console.log(
"[TS] Initial root file count:",
initialRootData.files.length,
);
console.log(
"[TS] Initial subfolder file count:",
initialSubfolderData.files.length,
);
// Verify subfolder has 20 files
expect(initialSubfolderData.files.length).toBeGreaterThanOrEqual(20);
// Check for content-addressed files (files with content identity)
const contentAddressedFiles = initialSubfolderData.files.filter(
(f: any) =>
f.kind === "File" &&
f.sd_path?.Content &&
f.alternate_paths?.length > 0,
);
const physicalOnlyFiles = initialSubfolderData.files.filter(
(f: any) => f.kind === "File" && f.sd_path?.Physical,
);
console.log(
"[TS] Content-addressed files (sd_path.Content):",
contentAddressedFiles.length,
);
console.log(
"[TS] Physical-only files (sd_path.Physical):",
physicalOnlyFiles.length,
);
// Log example paths for debugging
if (contentAddressedFiles.length > 0) {
const example = contentAddressedFiles[0];
console.log("[TS] Example content-addressed file:", {
name: example.name,
sd_path: example.sd_path,
alternate_paths: example.alternate_paths,
});
}
// CRITICAL: We need BOTH types to properly test the bug
// Content-addressed files expose the cache update bug
if (contentAddressedFiles.length === 0) {
console.warn(
"[TS] ⚠️ WARNING: No content-addressed files found! This test won't catch the production bug.",
);
console.warn(
"[TS] ⚠️ The cache update bug only affects files with sd_path.Content + alternate_paths.",
);
}
// Store initial file names from subfolder
const fileNames = initialSubfolderData.files
.filter((f: any) => f.kind === "File")
.slice(0, 20)
.map((f: any) => `${f.name}.${f.extension}`);
console.log(
"[TS] Moving 20 files from subfolder to root:",
fileNames.slice(0, 5),
"...",
);
// Move all 20 files
for (const fileName of fileNames) {
await rename(
join(subfolderPath, fileName),
join(rootPath, fileName),
);
}
// Wait for watcher to detect and process all moves
await new Promise((resolve) => setTimeout(resolve, 10000));
// Verify cache updated correctly
const finalRootData = rootResult.current.data as {
files: any[];
};
const finalSubfolderData = subfolderResult.current.data as {
files: any[];
};
const initialRootFileCount = initialRootData.files.filter(
(f: any) => f.kind === "File",
).length;
const finalRootFileCount = finalRootData.files.filter(
(f: any) => f.kind === "File",
).length;
const initialSubfolderFileCount = initialSubfolderData.files.filter(
(f: any) => f.kind === "File",
).length;
const finalSubfolderFileCount = finalSubfolderData.files.filter(
(f: any) => f.kind === "File",
).length;
console.log(
"[TS] Root files: before",
initialRootFileCount,
"→ after",
finalRootFileCount,
);
console.log(
"[TS] Subfolder files: before",
initialSubfolderFileCount,
"→ after",
finalSubfolderFileCount,
);
// 1. Verify root gained exactly 20 files
expect(finalRootFileCount).toBe(initialRootFileCount + 20);
// 2. Verify subfolder lost exactly 20 files
expect(finalSubfolderFileCount).toBe(initialSubfolderFileCount - 20);
// 3. Verify all 20 moved files are in root with correct paths and UUIDs preserved
const initialFileMap = new Map(
initialSubfolderData.files
.filter((f: any) => f.kind === "File")
.map((f: any) => [f.name, f]),
);
let movedFilesVerified = 0;
let contentAddressedMovedCount = 0;
let physicalOnlyMovedCount = 0;
for (const fileName of fileNames) {
const nameWithoutExt = fileName.split(".")[0];
// Find in final root
const fileInRoot = finalRootData.files.find(
(f: any) => f.name === nameWithoutExt && f.kind === "File",
);
// Find in final subfolder (should NOT be there)
const fileInSubfolder = finalSubfolderData.files.find(
(f: any) => f.name === nameWithoutExt && f.kind === "File",
);
// Get original file for UUID comparison
const originalFile = initialFileMap.get(nameWithoutExt);
if (fileInRoot && !fileInSubfolder && originalFile) {
// Verify UUID is preserved (proves it's a move, not delete+create)
expect(fileInRoot.id).toBe(originalFile.id);
// Track what type of file was moved successfully
if (fileInRoot.sd_path?.Content) {
contentAddressedMovedCount++;
// For content-addressed files, check alternate_paths
expect(fileInRoot.alternate_paths).toBeDefined();
expect(fileInRoot.alternate_paths.length).toBeGreaterThan(
0,
);
const physicalPath = fileInRoot.alternate_paths.find(
(p: any) => p.Physical,
)?.Physical?.path;
expect(physicalPath).toBeDefined();
expect(physicalPath).toContain(rootPath);
expect(physicalPath).not.toContain("bulk_test");
} else if (fileInRoot.sd_path?.Physical) {
physicalOnlyMovedCount++;
// For physical-only files, check sd_path directly
expect(fileInRoot.sd_path.Physical.path).toContain(
rootPath,
);
expect(fileInRoot.sd_path.Physical.path).not.toContain(
"bulk_test",
);
expect(fileInRoot.sd_path.Physical.path).toContain(
fileName,
);
}
movedFilesVerified++;
} else {
console.error(`[TS] ❌ File ${fileName} verification failed:`, {
inRoot: !!fileInRoot,
inSubfolder: !!fileInSubfolder,
hasOriginal: !!originalFile,
originalType: originalFile?.sd_path?.Content
? "Content"
: "Physical",
});
// Extra debugging for content-addressed files
if (originalFile?.sd_path?.Content) {
console.error(
`[TS] ⚠️ This was a content-addressed file - the cache update bug!`,
);
}
}
}
console.log(
"[TS] Verified",
movedFilesVerified,
"/ 20 files moved correctly",
);
console.log(
"[TS] - Content-addressed files moved:",
contentAddressedMovedCount,
);
console.log(
"[TS] - Physical-only files moved:",
physicalOnlyMovedCount,
);
expect(movedFilesVerified).toBe(20);
// This assertion will FAIL before the bug fix if any content-addressed files were present
// After the fix, this ensures content-addressed files are handled correctly
if (contentAddressedFiles.length > 0) {
console.log(
"[TS] ✓ Content-addressed files were successfully moved (bug fix verified)",
);
}
// 4. Verify no duplicates - files should not appear in both locations
const rootFileNames = new Set(
finalRootData.files
.filter((f: any) => f.kind === "File")
.map((f: any) => f.name),
);
const subfolderFileNames = new Set(
finalSubfolderData.files
.filter((f: any) => f.kind === "File")
.map((f: any) => f.name),
);
let duplicateCount = 0;
for (const fileName of fileNames) {
const nameWithoutExt = fileName.split(".")[0];
if (
rootFileNames.has(nameWithoutExt) &&
subfolderFileNames.has(nameWithoutExt)
) {
console.error(
`[TS] ❌ Duplicate found: ${fileName} appears in both locations!`,
);
duplicateCount++;
}
}
expect(duplicateCount).toBe(0);
console.log(
"[TS] ✓ Bulk file move detected and cache updated correctly",
);
console.log("[TS] ✓ All files have correct paths and preserved UUIDs");
console.log("[TS] ✓ No duplicates found");
}, 45000); // 45s timeout for bulk operations
});

View File

@@ -0,0 +1,239 @@
/**
* TypeScript Integration Test: useNormalizedQuery with Folder Renames
*
* This test is spawned by a Rust test harness that provides:
* - Real Spacedrive daemon running on Unix socket
* - Indexed location with test folders
* - Connection configuration via BRIDGE_CONFIG_PATH env var
*
* Test flow:
* 1. Connect to daemon using bridge config
* 2. Query directory listing with useNormalizedQuery
* 3. Rename folder in filesystem
* 4. Verify cache updates correctly and children remain accessible
*/
// Setup DOM environment before any other imports
import "./setup";
import {
describe,
test,
expect,
beforeAll,
afterAll,
afterEach,
} from "bun:test";
import { readFile } from "fs/promises";
import { rename } from "fs/promises";
import { join } from "path";
import { hostname } from "os";
import { SpacedriveClient } from "../../src/client";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import { SpacedriveProvider } from "../../src/hooks/useClient";
import { useNormalizedQuery } from "../../src/hooks/useNormalizedQuery";
import React from "react";
// Bridge configuration from Rust test harness
interface BridgeConfig {
socket_addr: string;
library_id: string;
location_db_id: number;
location_path: string;
test_data_path: string;
}
let bridgeConfig: BridgeConfig;
let client: SpacedriveClient;
const allEventsReceived: any[] = []; // Collect all events for debugging
beforeAll(async () => {
// Read bridge config from path provided by Rust test
const configPath = process.env.BRIDGE_CONFIG_PATH;
if (!configPath) {
throw new Error("BRIDGE_CONFIG_PATH environment variable not set");
}
console.log(`[TS] Reading bridge config from: ${configPath}`);
const configJson = await readFile(configPath, "utf-8");
bridgeConfig = JSON.parse(configJson);
console.log(`[TS] Bridge config:`, bridgeConfig);
// Connect to daemon via TCP socket
client = SpacedriveClient.fromTcpSocket(bridgeConfig.socket_addr);
console.log(`[TS] Connected to daemon`);
// Set library context
client.setCurrentLibrary(bridgeConfig.library_id);
console.log(`[TS] Library set to: ${bridgeConfig.library_id}`);
// Hook into the subscription manager to collect all events
const originalCreateSubscription = (client as any).subscriptionManager
.createSubscription;
(client as any).subscriptionManager.createSubscription = function (
filter: any,
callback: any,
) {
const wrappedCallback = (event: any) => {
allEventsReceived.push({
timestamp: new Date().toISOString(),
filter,
event,
});
console.log(
`[TS] 🔔 Event received:`,
JSON.stringify(event, null, 2),
);
callback(event);
};
return originalCreateSubscription.call(this, filter, wrappedCallback);
};
});
afterAll(async () => {
// Log all events at the end for debugging
console.log(
`[TS] ===== ALL EVENTS RECEIVED (${allEventsReceived.length}) =====`,
);
allEventsReceived.forEach((item, idx) => {
console.log(`[TS] Event ${idx + 1} at ${item.timestamp}:`);
console.log(`[TS] Filter:`, JSON.stringify(item.filter, null, 2));
console.log(`[TS] Event:`, JSON.stringify(item.event, null, 2));
});
console.log(`[TS] ===== END OF EVENTS =====`);
// No explicit disconnect needed for stateless transports
});
afterEach(() => {
// Clean up React components after each test
cleanup();
});
describe("useNormalizedQuery - Folder Rename Integration", () => {
test("should update cache when folder is renamed", async () => {
const locationPath = bridgeConfig.location_path;
const originalPath = join(locationPath, "original_folder");
const renamedPath = join(locationPath, "renamed_folder");
// Get device slug from hostname
const deviceSlug = hostname().toLowerCase().replace(/\s+/g, "-");
// Create wrapper for React hooks with SpacedriveProvider
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(SpacedriveProvider, { client }, children);
// Query root directory listing (should contain original_folder)
const { result: rootResult } = renderHook(
() =>
useNormalizedQuery({
wireMethod: "query:files.directory_listing",
input: {
path: {
Physical: {
device_slug: deviceSlug,
path: locationPath,
},
},
sort_by: "name",
},
resourceType: "file",
pathScope: {
Physical: {
device_slug: deviceSlug,
path: locationPath,
},
},
includeDescendants: false,
debug: true,
}),
{ wrapper },
);
// Query original_folder contents
const { result: folderResult } = renderHook(
() =>
useNormalizedQuery({
wireMethod: "query:files.directory_listing",
input: {
path: {
Physical: {
device_slug: deviceSlug,
path: originalPath,
},
},
sort_by: "name",
},
resourceType: "file",
pathScope: {
Physical: {
device_slug: deviceSlug,
path: originalPath,
},
},
includeDescendants: false,
debug: true,
}),
{ wrapper },
);
// Wait for initial data
await waitFor(() => {
expect(rootResult.current.data).toBeDefined();
expect(folderResult.current.data).toBeDefined();
});
console.log("[TS] Initial root listing:", rootResult.current.data);
console.log(
"[TS] Initial original_folder contents:",
folderResult.current.data,
);
// Verify initial state
const initialRootData = rootResult.current.data as { files: any[] };
const originalFolder = initialRootData.files.find(
(f: any) => f.name === "original_folder" && f.kind === "Directory",
);
expect(originalFolder).toBeDefined();
const originalFolderUuid = originalFolder.uuid;
const originalFolderChildrenData = folderResult.current.data as {
files: any[];
};
expect(originalFolderChildrenData.files.length).toBeGreaterThanOrEqual(
2,
); // file1.txt, file2.rs
// Rename the folder
console.log("[TS] Renaming folder: original_folder -> renamed_folder");
await rename(originalPath, renamedPath);
// Wait for watcher to detect and emit events
await new Promise((resolve) => setTimeout(resolve, 8000));
// Verify cache updated correctly
const finalRootData = rootResult.current.data as { files: any[] };
console.log("[TS] Final root listing:", finalRootData);
// Original folder should no longer exist in root
const originalStillExists = finalRootData.files.find(
(f: any) => f.name === "original_folder" && f.kind === "Directory",
);
expect(originalStillExists).toBeUndefined();
// Renamed folder should exist in root
const renamedFolder = finalRootData.files.find(
(f: any) => f.name === "renamed_folder" && f.kind === "Directory",
);
expect(renamedFolder).toBeDefined();
// UUID should be preserved (folder identity maintained)
expect(renamedFolder.uuid).toBe(originalFolderUuid);
console.log(
"[TS] ✓ Folder rename detected and cache updated correctly",
);
console.log("[TS] ✓ Folder UUID preserved:", renamedFolder.uuid);
}, 30000); // 30s timeout for watcher delays
});

View File

@@ -0,0 +1,254 @@
/**
* TypeScript Integration Test: useNormalizedQuery with File Moves
*
* This test is spawned by a Rust test harness that provides:
* - Real Spacedrive daemon running on Unix socket
* - Indexed location with test files
* - Connection configuration via BRIDGE_CONFIG_PATH env var
*
* Test flow:
* 1. Connect to daemon using bridge config
* 2. Query directory listing with useNormalizedQuery
* 3. Move files in filesystem
* 4. Verify cache updates correctly via WebSocket events
*/
// Setup DOM environment before any other imports
import "./setup";
import {
describe,
test,
expect,
beforeAll,
afterAll,
afterEach,
} from "bun:test";
import { readFile } from "fs/promises";
import { rename } from "fs/promises";
import { join } from "path";
import { hostname } from "os";
import { SpacedriveClient } from "../../src/client";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import { SpacedriveProvider } from "../../src/hooks/useClient";
import { useNormalizedQuery } from "../../src/hooks/useNormalizedQuery";
import React from "react";
// Bridge configuration from Rust test harness
interface BridgeConfig {
socket_addr: string;
library_id: string;
location_db_id: number;
location_path: string;
test_data_path: string;
}
let bridgeConfig: BridgeConfig;
let client: SpacedriveClient;
const allEventsReceived: any[] = []; // Collect all events for debugging
beforeAll(async () => {
// Read bridge config from path provided by Rust test
const configPath = process.env.BRIDGE_CONFIG_PATH;
if (!configPath) {
throw new Error("BRIDGE_CONFIG_PATH environment variable not set");
}
console.log(`[TS] Reading bridge config from: ${configPath}`);
const configJson = await readFile(configPath, "utf-8");
bridgeConfig = JSON.parse(configJson);
console.log(`[TS] Bridge config:`, bridgeConfig);
// Connect to daemon via TCP socket
client = SpacedriveClient.fromTcpSocket(bridgeConfig.socket_addr);
console.log(`[TS] Connected to daemon`);
// Set library context
client.setCurrentLibrary(bridgeConfig.library_id);
console.log(`[TS] Library set to: ${bridgeConfig.library_id}`);
// Hook into the subscription manager to collect all events
const originalCreateSubscription = (client as any).subscriptionManager
.createSubscription;
(client as any).subscriptionManager.createSubscription = function (
filter: any,
callback: any,
) {
const wrappedCallback = (event: any) => {
allEventsReceived.push({
timestamp: new Date().toISOString(),
filter,
event,
});
console.log(
`[TS] 🔔 Event received:`,
JSON.stringify(event, null, 2),
);
callback(event);
};
return originalCreateSubscription.call(this, filter, wrappedCallback);
};
});
afterAll(async () => {
// Log all events at the end for debugging
console.log(
`[TS] ===== ALL EVENTS RECEIVED (${allEventsReceived.length}) =====`,
);
allEventsReceived.forEach((item, idx) => {
console.log(`[TS] Event ${idx + 1} at ${item.timestamp}:`);
console.log(`[TS] Filter:`, JSON.stringify(item.filter, null, 2));
console.log(`[TS] Event:`, JSON.stringify(item.event, null, 2));
});
console.log(`[TS] ===== END OF EVENTS =====`);
// No explicit disconnect needed for stateless transports
});
afterEach(() => {
// Clean up React components after each test
cleanup();
});
describe("useNormalizedQuery - File Moves Integration", () => {
test("should update cache when file moves between folders", async () => {
const folderAPath = join(bridgeConfig.location_path, "folder_a");
const folderBPath = join(bridgeConfig.location_path, "folder_b");
// Get device slug from hostname
const deviceSlug = hostname().toLowerCase().replace(/\s+/g, "-");
// Create wrapper for React hooks with SpacedriveProvider
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(SpacedriveProvider, { client }, children);
// Query folder_a listing
const { result: folderAResult } = renderHook(
() =>
useNormalizedQuery({
wireMethod: "query:files.directory_listing",
input: {
path: {
Physical: {
device_slug: deviceSlug,
path: folderAPath,
},
},
sort_by: "name",
},
resourceType: "file",
pathScope: {
Physical: {
device_slug: deviceSlug,
path: folderAPath,
},
},
includeDescendants: false,
debug: true,
}),
{ wrapper },
);
// Query folder_b listing
const { result: folderBResult } = renderHook(
() =>
useNormalizedQuery({
wireMethod: "query:files.directory_listing",
input: {
path: {
Physical: {
device_slug: deviceSlug,
path: folderBPath,
},
},
sort_by: "name",
},
resourceType: "file",
pathScope: {
Physical: {
device_slug: deviceSlug,
path: folderBPath,
},
},
includeDescendants: false,
debug: true,
}),
{ wrapper },
);
// Wait for initial data
await waitFor(
() => {
console.log("[TS] Waiting for data...", {
folderA: {
data: folderAResult.current.data,
error: folderAResult.current.error,
isLoading: folderAResult.current.isLoading,
},
folderB: {
data: folderBResult.current.data,
error: folderBResult.current.error,
isLoading: folderBResult.current.isLoading,
},
});
expect(folderAResult.current.data).toBeDefined();
expect(folderBResult.current.data).toBeDefined();
},
{ timeout: 5000 },
);
console.log("[TS] Initial folder_a files:", folderAResult.current.data);
console.log("[TS] Initial folder_b files:", folderBResult.current.data);
// Verify initial state
const initialFolderAData = folderAResult.current.data as {
files: any[];
};
const initialFolderBData = folderBResult.current.data as {
files: any[];
};
expect(initialFolderAData.files.length).toBeGreaterThanOrEqual(2); // file1.txt, file2.rs
expect(initialFolderBData.files.length).toBeGreaterThanOrEqual(1); // file3.md
const file1Before = initialFolderAData.files.find(
(f: any) => f.name === "file1",
);
expect(file1Before).toBeDefined();
// Move file1.txt from folder_a to folder_b
console.log("[TS] Moving file1.txt from folder_a to folder_b");
await rename(
join(folderAPath, "file1.txt"),
join(folderBPath, "file1.txt"),
);
// Wait for watcher to detect and emit events (watcher buffers for 500ms + tick time)
await new Promise((resolve) => setTimeout(resolve, 8000));
// Verify cache updated correctly
const finalFolderAData = folderAResult.current.data as { files: any[] };
const finalFolderBData = folderBResult.current.data as { files: any[] };
console.log("[TS] Final folder_a files:", finalFolderAData);
console.log("[TS] Final folder_b files:", finalFolderBData);
// file1 should no longer be in folder_a
const file1InFolderA = finalFolderAData.files.find(
(f: any) => f.name === "file1",
);
expect(file1InFolderA).toBeUndefined();
// file1 should now be in folder_b
const file1InFolderB = finalFolderBData.files.find(
(f: any) => f.name === "file1",
);
expect(file1InFolderB).toBeDefined();
// UUID should be preserved (move detection)
expect(file1InFolderB.uuid).toBe(file1Before.uuid);
console.log("[TS] ✓ File move detected and cache updated correctly");
}, 30000); // 30s timeout for watcher delays
});