mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-19 05:45:01 -04:00
Implement TypeScript integration tests with Rust bridge
- Added a new test suite in `core/tests/typescript_bridge_test.rs` to facilitate end-to-end testing between Rust and TypeScript. - Introduced `IndexingHarnessBuilder` enhancements to support a daemon RPC server for TypeScript tests. - Created TypeScript test files for `useNormalizedQuery` to validate file move and folder rename operations. - Configured Bun test environment with `bunfig.toml` and setup scripts for improved testing efficiency. - Updated `TcpSocketTransport` to handle TCP connections for the TypeScript client, ensuring robust communication with the Rust backend. - Enhanced logging and error handling for better visibility during test execution.
This commit is contained in:
@@ -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);
|
||||
|
||||
381
core/tests/typescript_bridge_test.rs
Normal file
381
core/tests/typescript_bridge_test.rs
Normal file
@@ -0,0 +1,381 @@
|
||||
//! 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
|
||||
test_location.create_dir("bulk_test").await?;
|
||||
for i in 1..=20 {
|
||||
test_location
|
||||
.write_file(
|
||||
&format!("bulk_test/file{:02}.txt", i),
|
||||
&format!("Content of file {}", i),
|
||||
)
|
||||
.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
|
||||
let location = test_location
|
||||
.index("TypeScript Bulk Test Location", IndexMode::Shallow)
|
||||
.await?;
|
||||
|
||||
// Wait for indexing to complete
|
||||
tokio::time::sleep(Duration::from_secs(2)).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 - run only the bulk moves test
|
||||
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 (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)
|
||||
.arg("--test-name-pattern")
|
||||
.arg("should update cache when moving 20 files from subfolder to root")
|
||||
.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(())
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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]
|
||||
|
||||
11
packages/ts-client/tests/integration/setup.ts
Normal file
11
packages/ts-client/tests/integration/setup.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 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();
|
||||
@@ -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