mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-19 13:55:40 -04:00
Merge branch 'main' into feature/file-004-rename-and-folders
This commit is contained in:
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
400
core/tests/typescript_bridge_test.rs
Normal file
400
core/tests/typescript_bridge_test.rs
Normal 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(())
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
279
packages/ts-client/tests/integration/README.md
Normal file
279
packages/ts-client/tests/integration/README.md
Normal 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
|
||||
4
packages/ts-client/tests/integration/bunfig.toml
Normal file
4
packages/ts-client/tests/integration/bunfig.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
# Bun test configuration for integration tests
|
||||
# Note: setup.ts is imported directly in test files rather than preloaded
|
||||
[test]
|
||||
|
||||
29
packages/ts-client/tests/integration/setup.ts
Normal file
29
packages/ts-client/tests/integration/setup.ts
Normal 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);
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
254
packages/ts-client/tests/integration/useNormalizedQuery.test.ts
Normal file
254
packages/ts-client/tests/integration/useNormalizedQuery.test.ts
Normal 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
|
||||
});
|
||||
Reference in New Issue
Block a user