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:
Jamie Pine
2025-12-25 06:45:17 -08:00
parent d3747ccd53
commit caf5a242fd
11 changed files with 1358 additions and 2 deletions

BIN
bun.lockb
View File

Binary file not shown.

View File

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

View File

@@ -0,0 +1,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(())
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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();

View File

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

View File

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