From 921c76b01285c825fbcf948e4f5e27cfd2c78ba9 Mon Sep 17 00:00:00 2001 From: = Date: Thu, 22 Jan 2026 21:25:11 -0500 Subject: [PATCH 001/118] Pre-build SpacetimeDB binaries once for tests Use OnceLock to build spacetimedb-cli and spacetimedb-standalone once per test process, then run the pre-built binary directly instead of using `cargo run`. This avoids repeated cargo overhead and ensures consistent binary reuse across parallel tests. --- crates/guard/src/lib.rs | 100 ++++++++++++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 25 deletions(-) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 147bddf16b4..a42abbbef52 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -4,12 +4,75 @@ use std::{ env, io::{BufRead, BufReader}, net::SocketAddr, + path::PathBuf, process::{Child, Command, Stdio}, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, OnceLock}, thread::{self, sleep}, time::{Duration, Instant}, }; +/// Lazily-initialized path to the pre-built CLI binary. +static CLI_BINARY_PATH: OnceLock = OnceLock::new(); + +/// Ensures `spacetimedb-cli` and `spacetimedb-standalone` are built once, +/// returning the path to the CLI binary. +fn ensure_binaries_built() -> PathBuf { + CLI_BINARY_PATH + .get_or_init(|| { + // Navigate from crates/guard/ to workspace root + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() // crates/ + .and_then(|p| p.parent()) // workspace root + .expect("Failed to find workspace root"); + + // Determine target directory + let target_dir = env::var("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| workspace_root.join("target")); + + // Determine profile + let profile = if cfg!(debug_assertions) { + "debug" + } else { + "release" + }; + + // Build both binaries (standalone needed by CLI's start command) + for pkg in ["spacetimedb-standalone", "spacetimedb-cli"] { + let mut args = vec!["build", "-p", pkg]; + if profile == "release" { + args.push("--release"); + } + + let status = Command::new("cargo") + .args(&args) + .current_dir(workspace_root) + .status() + .unwrap_or_else(|e| panic!("Failed to build {}: {}", pkg, e)); + + assert!(status.success(), "Building {} failed", pkg); + } + + // Return path to CLI binary + let cli_name = if cfg!(windows) { + "spacetimedb-cli.exe" + } else { + "spacetimedb-cli" + }; + let cli_path = target_dir.join(profile).join(cli_name); + + assert!( + cli_path.exists(), + "CLI binary not found at {}", + cli_path.display() + ); + + cli_path + }) + .clone() +} + use reqwest::blocking::Client; pub struct SpacetimeDbGuard { @@ -45,9 +108,6 @@ impl SpacetimeDbGuard { // Using loopback avoids needing to "connect to 0.0.0.0". let address = "127.0.0.1:0".to_string(); - // Workspace root for `cargo run -p ...` - let workspace_dir = env!("CARGO_MANIFEST_DIR"); - let mut args = vec![]; let (child, logs) = if use_installed_cli { @@ -57,13 +117,20 @@ impl SpacetimeDbGuard { let cmd = Command::new("spacetime"); Self::spawn_child(cmd, env!("CARGO_MANIFEST_DIR"), &args) } else { - Self::build_prereqs(workspace_dir); - args.extend(vec!["run", "-p", "spacetimedb-cli", "--"]); + let cli_path = ensure_binaries_built(); + args.extend(extra_args); args.extend(["--listen-addr", &address]); - let cmd = Command::new("cargo"); - Self::spawn_child(cmd, workspace_dir, &args) + let cmd = Command::new(&cli_path); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() + .and_then(|p| p.parent()) + .expect("Failed to find workspace root"); + + Self::spawn_child(cmd, workspace_root.to_str().unwrap(), &args) }; // Parse the actual bound address from logs. @@ -75,23 +142,6 @@ impl SpacetimeDbGuard { guard } - // Ensure standalone is built before we start, if that’s needed. - // This is best-effort and usually a no-op when already built. - // Also build the CLI before running it to avoid that being included in the - // timeout for readiness. - fn build_prereqs(workspace_dir: &str) { - let targets = ["spacetimedb-standalone", "spacetimedb-cli"]; - - for pkg in targets { - let mut cmd = Command::new("cargo"); - let _ = cmd - .args(["build", "-p", pkg]) - .current_dir(workspace_dir) - .status() - .unwrap_or_else(|_| panic!("failed to build {}", pkg)); - } - } - fn spawn_child(mut cmd: Command, workspace_dir: &str, args: &[&str]) -> (Child, Arc>) { let mut child = cmd .args(args) From 0df9a8b01dd27043ae073c16d8663666c96591bc Mon Sep 17 00:00:00 2001 From: = Date: Thu, 22 Jan 2026 22:15:54 -0500 Subject: [PATCH 002/118] Add Rust smoketests crate with sql and call test translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create `crates/smoketests/` to translate Python smoketests to Rust: - Add `Smoketest` struct with builder pattern for test setup - Implement CLI helpers: `spacetime_cmd()`, `call()`, `sql()`, `logs()`, etc. - Translate `smoketests/tests/sql.py` → `tests/sql.rs` - Translate `smoketests/tests/call.py` → `tests/call.rs` - Reuse `ensure_binaries_built()` from guard crate (now public) Also fix Windows process cleanup in `SpacetimeDbGuard`: - Use `taskkill /F /T /PID` to kill entire process tree - Prevents orphaned `spacetimedb-standalone.exe` processes --- Cargo.lock | 14 ++ Cargo.toml | 1 + crates/guard/src/lib.rs | 25 +- crates/smoketests/Cargo.toml | 21 ++ crates/smoketests/src/lib.rs | 405 ++++++++++++++++++++++++++++++++ crates/smoketests/tests/call.rs | 244 +++++++++++++++++++ crates/smoketests/tests/sql.rs | 194 +++++++++++++++ 7 files changed, 901 insertions(+), 3 deletions(-) create mode 100644 crates/smoketests/Cargo.toml create mode 100644 crates/smoketests/src/lib.rs create mode 100644 crates/smoketests/tests/call.rs create mode 100644 crates/smoketests/tests/sql.rs diff --git a/Cargo.lock b/Cargo.lock index a85d10076bc..f8e050114ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8224,6 +8224,20 @@ dependencies = [ "tokio-tungstenite", ] +[[package]] +name = "spacetimedb-smoketests" +version = "1.11.3" +dependencies = [ + "anyhow", + "cargo_metadata", + "regex", + "serde", + "serde_json", + "spacetimedb-guard", + "tempfile", + "toml 0.8.23", +] + [[package]] name = "spacetimedb-snapshot" version = "1.11.3" diff --git a/Cargo.toml b/Cargo.toml index 9696129e3b9..a01d52d9db1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "crates/query", "crates/sats", "crates/schema", + "crates/smoketests", "sdks/rust", "sdks/unreal", "crates/snapshot", diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index a42abbbef52..405821bf05b 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -16,7 +16,9 @@ static CLI_BINARY_PATH: OnceLock = OnceLock::new(); /// Ensures `spacetimedb-cli` and `spacetimedb-standalone` are built once, /// returning the path to the CLI binary. -fn ensure_binaries_built() -> PathBuf { +/// +/// This is useful for tests that need to run CLI commands directly. +pub fn ensure_binaries_built() -> PathBuf { CLI_BINARY_PATH .get_or_init(|| { // Navigate from crates/guard/ to workspace root @@ -245,8 +247,25 @@ fn parse_listen_addr_from_line(line: &str) -> Option { impl Drop for SpacetimeDbGuard { fn drop(&mut self) { - // Best-effort cleanup. - let _ = self.child.kill(); + // Kill the process tree to ensure all child processes are terminated. + // On Windows, child.kill() only kills the direct child (spacetimedb-cli), + // leaving spacetimedb-standalone running as an orphan. + #[cfg(windows)] + { + let pid = self.child.id(); + // Use taskkill /T to kill the process tree + let _ = Command::new("taskkill") + .args(["/F", "/T", "/PID", &pid.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + + #[cfg(not(windows))] + { + let _ = self.child.kill(); + } + let _ = self.child.wait(); // Only print logs if the test is currently panicking diff --git a/crates/smoketests/Cargo.toml b/crates/smoketests/Cargo.toml new file mode 100644 index 00000000000..faad83892f0 --- /dev/null +++ b/crates/smoketests/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "spacetimedb-smoketests" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +# Test utilities (needed in lib for test helpers) +spacetimedb-guard.workspace = true +tempfile.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true +regex.workspace = true +anyhow.workspace = true + +[dev-dependencies] +cargo_metadata.workspace = true + +[lints] +workspace = true diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs new file mode 100644 index 00000000000..43b9410f77e --- /dev/null +++ b/crates/smoketests/src/lib.rs @@ -0,0 +1,405 @@ +//! Rust smoketest infrastructure for SpacetimeDB. +//! +//! This crate provides utilities for writing end-to-end tests that compile and publish +//! SpacetimeDB modules, then exercise them via CLI commands. +//! +//! # Example +//! +//! ```ignore +//! use spacetimedb_smoketests::Smoketest; +//! +//! const MODULE_CODE: &str = r#" +//! use spacetimedb::{table, reducer}; +//! +//! #[spacetimedb::table(name = person, public)] +//! pub struct Person { +//! name: String, +//! } +//! +//! #[spacetimedb::reducer] +//! pub fn add(ctx: &ReducerContext, name: String) { +//! ctx.db.person().insert(Person { name }); +//! } +//! "#; +//! +//! #[test] +//! fn test_example() { +//! let mut test = Smoketest::builder() +//! .module_code(MODULE_CODE) +//! .build(); +//! +//! test.call("add", &["Alice"]).unwrap(); +//! test.assert_sql("SELECT * FROM person", "name\n-----\nAlice"); +//! } +//! ``` + +use anyhow::{bail, Context, Result}; +use regex::Regex; +use serde::Serialize; +use spacetimedb_guard::{ensure_binaries_built, SpacetimeDbGuard}; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Output, Stdio}; + +/// Returns the workspace root directory. +fn workspace_root() -> PathBuf { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest_dir + .parent() + .and_then(|p| p.parent()) + .expect("Failed to find workspace root") + .to_path_buf() +} + +/// A smoketest instance that manages a SpacetimeDB server and module project. +pub struct Smoketest { + /// The SpacetimeDB server guard (stops server on drop). + pub guard: SpacetimeDbGuard, + /// Temporary directory containing the module project. + pub project_dir: tempfile::TempDir, + /// Database identity after publishing (if any). + pub database_identity: Option, + /// The server URL (e.g., "http://127.0.0.1:3000"). + pub server_url: String, +} + +/// Builder for creating `Smoketest` instances. +pub struct SmoketestBuilder { + module_code: Option, + bindings_features: Vec, + extra_deps: String, + autopublish: bool, +} + +impl Default for SmoketestBuilder { + fn default() -> Self { + Self::new() + } +} + +impl SmoketestBuilder { + /// Creates a new builder with default settings. + pub fn new() -> Self { + Self { + module_code: None, + bindings_features: vec!["unstable".to_string()], + extra_deps: String::new(), + autopublish: true, + } + } + + /// Sets the module code to compile and publish. + pub fn module_code(mut self, code: &str) -> Self { + self.module_code = Some(code.to_string()); + self + } + + /// Sets additional features for the spacetimedb bindings dependency. + pub fn bindings_features(mut self, features: &[&str]) -> Self { + self.bindings_features = features.iter().map(|s| s.to_string()).collect(); + self + } + + /// Adds extra dependencies to the module's Cargo.toml. + pub fn extra_deps(mut self, deps: &str) -> Self { + self.extra_deps = deps.to_string(); + self + } + + /// Sets whether to automatically publish the module on build. + /// Default is true. + pub fn autopublish(mut self, yes: bool) -> Self { + self.autopublish = yes; + self + } + + /// Builds the `Smoketest` instance. + /// + /// This spawns a SpacetimeDB server, creates a temporary project directory, + /// writes the module code, and optionally publishes the module. + pub fn build(self) -> Smoketest { + let guard = SpacetimeDbGuard::spawn_in_temp_data_dir(); + let project_dir = tempfile::tempdir().expect("Failed to create temp project directory"); + + // Create project structure + fs::create_dir_all(project_dir.path().join("src")).expect("Failed to create src directory"); + + // Write Cargo.toml + let workspace_root = workspace_root(); + let bindings_path = workspace_root.join("crates/bindings"); + let bindings_path_str = bindings_path.display().to_string().replace('\\', "/"); + let features_str = format!("{:?}", self.bindings_features); + + let cargo_toml = format!( + r#"[package] +name = "smoketest-module" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb = {{ path = "{}", features = {} }} +log = "0.4" +{} +"#, + bindings_path_str, features_str, self.extra_deps + ); + fs::write(project_dir.path().join("Cargo.toml"), cargo_toml) + .expect("Failed to write Cargo.toml"); + + // Copy rust-toolchain.toml + let toolchain_src = workspace_root.join("rust-toolchain.toml"); + if toolchain_src.exists() { + fs::copy(&toolchain_src, project_dir.path().join("rust-toolchain.toml")) + .expect("Failed to copy rust-toolchain.toml"); + } + + // Write module code + let module_code = self.module_code.unwrap_or_else(|| { + r#"use spacetimedb::ReducerContext; + +#[spacetimedb::reducer] +pub fn noop(_ctx: &ReducerContext) {} +"# + .to_string() + }); + fs::write(project_dir.path().join("src/lib.rs"), &module_code).expect("Failed to write lib.rs"); + + let server_url = guard.host_url.clone(); + let mut smoketest = Smoketest { + guard, + project_dir, + database_identity: None, + server_url, + }; + + if self.autopublish { + smoketest.publish_module().expect("Failed to publish module"); + } + + smoketest + } +} + +impl Smoketest { + /// Creates a new builder for configuring a smoketest. + pub fn builder() -> SmoketestBuilder { + SmoketestBuilder::new() + } + + + /// Runs a spacetime CLI command with the configured server. + /// + /// Returns the command output. The command is run but not yet asserted. + /// The `--server` flag is automatically inserted after the first argument (the subcommand). + pub fn spacetime_cmd(&self, args: &[&str]) -> Output { + let cli_path = ensure_binaries_built(); + let mut cmd = Command::new(&cli_path); + + // Insert --server after the subcommand + if let Some((subcommand, rest)) = args.split_first() { + cmd.arg(subcommand) + .arg("--server") + .arg(&self.server_url) + .args(rest); + } + + cmd.current_dir(self.project_dir.path()) + .output() + .expect("Failed to execute spacetime command") + } + + /// Runs a spacetime CLI command and returns stdout as a string. + /// + /// Panics if the command fails. + pub fn spacetime(&self, args: &[&str]) -> Result { + let output = self.spacetime_cmd(args); + if !output.status.success() { + bail!( + "spacetime {:?} failed:\nstdout: {}\nstderr: {}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + + /// Writes new module code to the project. + pub fn write_module_code(&self, code: &str) -> Result<()> { + fs::write(self.project_dir.path().join("src/lib.rs"), code) + .context("Failed to write module code")?; + Ok(()) + } + + /// Publishes the module and stores the database identity. + pub fn publish_module(&mut self) -> Result { + let project_path = self.project_dir.path().to_str().unwrap().to_string(); + let output = self.spacetime(&[ + "publish", + "--project-path", + &project_path, + "--yes", + ])?; + + // Parse the identity from output like "identity: abc123..." + let re = Regex::new(r"identity: ([0-9a-fA-F]+)").unwrap(); + if let Some(caps) = re.captures(&output) { + let identity = caps.get(1).unwrap().as_str().to_string(); + self.database_identity = Some(identity.clone()); + Ok(identity) + } else { + bail!("Failed to parse database identity from publish output: {}", output); + } + } + + /// Calls a reducer or procedure with the given arguments. + /// + /// Arguments are serialized to JSON. + pub fn call(&self, name: &str, args: &[T]) -> Result { + let identity = self + .database_identity + .as_ref() + .context("No database published")?; + + let mut cmd_args = vec!["call", "--", identity.as_str(), name]; + let json_args: Vec = args.iter().map(|a| serde_json::to_string(a).unwrap()).collect(); + let json_refs: Vec<&str> = json_args.iter().map(|s| s.as_str()).collect(); + cmd_args.extend(json_refs); + + self.spacetime(&cmd_args) + } + + /// Calls a reducer or procedure with raw string arguments (no JSON serialization). + pub fn call_raw(&self, name: &str, args: &[&str]) -> Result { + let identity = self + .database_identity + .as_ref() + .context("No database published")?; + + let mut cmd_args = vec!["call", "--", identity.as_str(), name]; + cmd_args.extend(args); + + self.spacetime(&cmd_args) + } + + /// Calls a reducer/procedure and returns the full output including stderr. + pub fn call_output(&self, name: &str, args: &[&str]) -> Output { + let identity = self + .database_identity + .as_ref() + .expect("No database published"); + + let mut cmd_args = vec!["call", "--", identity.as_str(), name]; + cmd_args.extend(args); + + self.spacetime_cmd(&cmd_args) + } + + /// Executes a SQL query against the database. + pub fn sql(&self, query: &str) -> Result { + let identity = self + .database_identity + .as_ref() + .context("No database published")?; + + self.spacetime(&["sql", identity.as_str(), query]) + } + + /// Asserts that a SQL query produces the expected output. + /// + /// Both the actual output and expected string have trailing whitespace + /// trimmed from each line for comparison. + pub fn assert_sql(&self, query: &str, expected: &str) { + let actual = self.sql(query).expect("SQL query failed"); + let actual_normalized = normalize_whitespace(&actual); + let expected_normalized = normalize_whitespace(expected); + + assert_eq!( + actual_normalized, expected_normalized, + "SQL output mismatch for query: {}\n\nExpected:\n{}\n\nActual:\n{}", + query, expected_normalized, actual_normalized + ); + } + + /// Fetches the last N log entries from the database. + pub fn logs(&self, n: usize) -> Result> { + let records = self.log_records(n)?; + Ok(records + .into_iter() + .filter_map(|r| r.get("message").and_then(|m| m.as_str()).map(String::from)) + .collect()) + } + + /// Fetches the last N log records as JSON values. + pub fn log_records(&self, n: usize) -> Result> { + let identity = self + .database_identity + .as_ref() + .context("No database published")?; + + let output = self.spacetime(&["logs", "--format=json", "-n", &n.to_string(), "--", identity])?; + + output + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).context("Failed to parse log record")) + .collect() + } + + /// Starts a subscription and waits for N updates. + /// + /// Returns the updates as JSON values. + pub fn subscribe(&self, queries: &[&str], n: usize) -> Result> { + let identity = self + .database_identity + .as_ref() + .context("No database published")?; + + let cli_path = ensure_binaries_built(); + let mut cmd = Command::new(&cli_path); + cmd.args(["subscribe", "--server", &self.server_url, identity, "-t", "600", "-n", &n.to_string(), "--print-initial-update", "--"]) + .args(queries) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let output = cmd.output().context("Failed to run subscribe command")?; + + if !output.status.success() { + bail!( + "subscribe failed:\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).context("Failed to parse subscription update")) + .collect() + } +} + +/// Normalizes whitespace by trimming trailing whitespace from each line. +fn normalize_whitespace(s: &str) -> String { + s.lines() + .map(|line| line.trim_end()) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_whitespace() { + let input = "hello \nworld \n foo "; + let expected = "hello\nworld\n foo"; + assert_eq!(normalize_whitespace(input), expected); + } +} diff --git a/crates/smoketests/tests/call.rs b/crates/smoketests/tests/call.rs new file mode 100644 index 00000000000..0d8a917fd81 --- /dev/null +++ b/crates/smoketests/tests/call.rs @@ -0,0 +1,244 @@ +//! Reducer/procedure call tests translated from smoketests/tests/call.py + +use spacetimedb_smoketests::Smoketest; + +const CALL_REDUCER_PROCEDURE_MODULE_CODE: &str = r#" +use spacetimedb::{log, ProcedureContext, ReducerContext, Table}; + +#[spacetimedb::table(name = person)] +pub struct Person { + name: String, +} + +#[spacetimedb::reducer] +pub fn say_hello(_ctx: &ReducerContext) { + log::info!("Hello, World!"); +} + +#[spacetimedb::procedure] +pub fn return_person(_ctx: &mut ProcedureContext) -> Person { + return Person { name: "World".to_owned() }; +} +"#; + +/// Check calling a reducer (no return) and procedure (return) +#[test] +fn test_call_reducer_procedure() { + let test = Smoketest::builder() + .module_code(CALL_REDUCER_PROCEDURE_MODULE_CODE) + .build(); + + // Reducer returns empty + let msg = test.call_raw("say_hello", &[]).unwrap(); + assert_eq!(msg.trim(), ""); + + // Procedure returns a value + let msg = test.call_raw("return_person", &[]).unwrap(); + assert_eq!(msg.trim(), r#"["World"]"#); +} + +/// Check calling a non-existent reducer/procedure raises error +#[test] +fn test_call_errors() { + let test = Smoketest::builder() + .module_code(CALL_REDUCER_PROCEDURE_MODULE_CODE) + .build(); + + let identity = test.database_identity.as_ref().unwrap(); + + // Non-existent reducer + let output = test.call_output("non_existent_reducer", &[]); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + let expected = format!( + "WARNING: This command is UNSTABLE and subject to breaking changes. + +Error: No such reducer OR procedure `non_existent_reducer` for database `{identity}` resolving to identity `{identity}`. + +Here are some existing reducers: +- say_hello + +Here are some existing procedures: +- return_person" + ); + assert!( + expected.contains(stderr.trim()), + "Expected stderr to be contained in expected message.\nExpected:\n{}\n\nActual stderr:\n{}", + expected, + stderr.trim() + ); + + // Non-existent procedure + let output = test.call_output("non_existent_procedure", &[]); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + let expected = format!( + "WARNING: This command is UNSTABLE and subject to breaking changes. + +Error: No such reducer OR procedure `non_existent_procedure` for database `{identity}` resolving to identity `{identity}`. + +Here are some existing reducers: +- say_hello + +Here are some existing procedures: +- return_person" + ); + assert!( + expected.contains(stderr.trim()), + "Expected stderr to be contained in expected message.\nExpected:\n{}\n\nActual stderr:\n{}", + expected, + stderr.trim() + ); + + // Similar name to reducer - should suggest similar + let output = test.call_output("say_hell", &[]); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + let expected = format!( + "WARNING: This command is UNSTABLE and subject to breaking changes. + +Error: No such reducer OR procedure `say_hell` for database `{identity}` resolving to identity `{identity}`. + +A reducer with a similar name exists: `say_hello`" + ); + assert!( + expected.contains(stderr.trim()), + "Expected stderr to be contained in expected message.\nExpected:\n{}\n\nActual stderr:\n{}", + expected, + stderr.trim() + ); + + // Similar name to procedure - should suggest similar + let output = test.call_output("return_perso", &[]); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + let expected = format!( + "WARNING: This command is UNSTABLE and subject to breaking changes. + +Error: No such reducer OR procedure `return_perso` for database `{identity}` resolving to identity `{identity}`. + +A procedure with a similar name exists: `return_person`" + ); + assert!( + expected.contains(stderr.trim()), + "Expected stderr to be contained in expected message.\nExpected:\n{}\n\nActual stderr:\n{}", + expected, + stderr.trim() + ); +} + +const CALL_EMPTY_MODULE_CODE: &str = r#" +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person)] +pub struct Person { + name: String, +} +"#; + +/// Check calling into a database with no reducers/procedures raises error +#[test] +fn test_call_empty_errors() { + let test = Smoketest::builder() + .module_code(CALL_EMPTY_MODULE_CODE) + .build(); + + let identity = test.database_identity.as_ref().unwrap(); + + let output = test.call_output("non_existent", &[]); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + let expected = format!( + "WARNING: This command is UNSTABLE and subject to breaking changes. + +Error: No such reducer OR procedure `non_existent` for database `{identity}` resolving to identity `{identity}`. + +The database has no reducers. + +The database has no procedures." + ); + assert!( + expected.contains(stderr.trim()), + "Expected stderr to be contained in expected message.\nExpected:\n{}\n\nActual stderr:\n{}", + expected, + stderr.trim() + ); +} + +/// Generate module code with many reducers and procedures +fn generate_many_module_code() -> String { + let mut code = String::from( + r#" +use spacetimedb::{log, ProcedureContext, ReducerContext}; +"#, + ); + + for i in 0..11 { + code.push_str(&format!( + r#" +#[spacetimedb::reducer] +pub fn say_reducer_{i}(_ctx: &ReducerContext) {{ + log::info!("Hello from reducer {i}!"); +}} + +#[spacetimedb::procedure] +pub fn say_procedure_{i}(_ctx: &mut ProcedureContext) {{ + log::info!("Hello from procedure {i}!"); +}} +"# + )); + } + + code +} + +/// Check calling into a database with many reducers/procedures raises error with listing +#[test] +fn test_call_many_errors() { + let module_code = generate_many_module_code(); + let test = Smoketest::builder().module_code(&module_code).build(); + + let identity = test.database_identity.as_ref().unwrap(); + + let output = test.call_output("non_existent", &[]); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + + let expected = format!( + "WARNING: This command is UNSTABLE and subject to breaking changes. + +Error: No such reducer OR procedure `non_existent` for database `{identity}` resolving to identity `{identity}`. + +Here are some existing reducers: +- say_reducer_0 +- say_reducer_1 +- say_reducer_2 +- say_reducer_3 +- say_reducer_4 +- say_reducer_5 +- say_reducer_6 +- say_reducer_7 +- say_reducer_8 +- say_reducer_9 +... (1 reducer not shown) + +Here are some existing procedures: +- say_procedure_0 +- say_procedure_1 +- say_procedure_2 +- say_procedure_3 +- say_procedure_4 +- say_procedure_5 +- say_procedure_6 +- say_procedure_7 +- say_procedure_8 +- say_procedure_9 +... (1 procedure not shown)" + ); + assert!( + expected.contains(stderr.trim()), + "Expected stderr to be contained in expected message.\nExpected:\n{}\n\nActual stderr:\n{}", + expected, + stderr.trim() + ); +} diff --git a/crates/smoketests/tests/sql.rs b/crates/smoketests/tests/sql.rs new file mode 100644 index 00000000000..0e8571959e3 --- /dev/null +++ b/crates/smoketests/tests/sql.rs @@ -0,0 +1,194 @@ +//! SQL format tests translated from smoketests/tests/sql.py + +use spacetimedb_smoketests::Smoketest; + +const SQL_FORMAT_MODULE_CODE: &str = r#" +use spacetimedb::sats::{i256, u256}; +use spacetimedb::{ConnectionId, Identity, ReducerContext, Table, Timestamp, TimeDuration, SpacetimeType, Uuid}; + +#[derive(Copy, Clone)] +#[spacetimedb::table(name = t_ints)] +pub struct TInts { + i8: i8, + i16: i16, + i32: i32, + i64: i64, + i128: i128, + i256: i256, +} + +#[spacetimedb::table(name = t_ints_tuple)] +pub struct TIntsTuple { + tuple: TInts, +} + +#[derive(Copy, Clone)] +#[spacetimedb::table(name = t_uints)] +pub struct TUints { + u8: u8, + u16: u16, + u32: u32, + u64: u64, + u128: u128, + u256: u256, +} + +#[spacetimedb::table(name = t_uints_tuple)] +pub struct TUintsTuple { + tuple: TUints, +} + +#[derive(Clone)] +#[spacetimedb::table(name = t_others)] +pub struct TOthers { + bool: bool, + f32: f32, + f64: f64, + str: String, + bytes: Vec, + identity: Identity, + connection_id: ConnectionId, + timestamp: Timestamp, + duration: TimeDuration, + uuid: Uuid, +} + +#[spacetimedb::table(name = t_others_tuple)] +pub struct TOthersTuple { + tuple: TOthers +} + +#[derive(SpacetimeType, Debug, Clone, Copy)] +pub enum Action { + Inactive, + Active, +} + +#[derive(Clone)] +#[spacetimedb::table(name = t_enums)] +pub struct TEnums { + bool_opt: Option, + bool_result: Result, + action: Action, +} + +#[spacetimedb::table(name = t_enums_tuple)] +pub struct TEnumsTuple { + tuple: TEnums, +} + +#[spacetimedb::reducer] +pub fn test(ctx: &ReducerContext) { + let tuple = TInts { + i8: -25, + i16: -3224, + i32: -23443, + i64: -2344353, + i128: -234434897853, + i256: (-234434897853i128).into(), + }; + ctx.db.t_ints().insert(tuple); + ctx.db.t_ints_tuple().insert(TIntsTuple { tuple }); + + let tuple = TUints { + u8: 105, + u16: 1050, + u32: 83892, + u64: 48937498, + u128: 4378528978889, + u256: 4378528978889u128.into(), + }; + ctx.db.t_uints().insert(tuple); + ctx.db.t_uints_tuple().insert(TUintsTuple { tuple }); + + let tuple = TOthers { + bool: true, + f32: 594806.58906, + f64: -3454353.345389043278459, + str: "This is spacetimedb".to_string(), + bytes: vec!(1, 2, 3, 4, 5, 6, 7), + identity: Identity::ONE, + connection_id: ConnectionId::ZERO, + timestamp: Timestamp::UNIX_EPOCH, + duration: TimeDuration::ZERO, + uuid: Uuid::NIL, + }; + ctx.db.t_others().insert(tuple.clone()); + ctx.db.t_others_tuple().insert(TOthersTuple { tuple }); + + let tuple = TEnums { + bool_opt: Some(true), + bool_result: Ok(false), + action: Action::Active, + }; + + ctx.db.t_enums().insert(tuple.clone()); + ctx.db.t_enums_tuple().insert(TEnumsTuple { tuple }); +} +"#; + +/// This test is designed to test the format of the output of sql queries +#[test] +fn test_sql_format() { + let test = Smoketest::builder() + .module_code(SQL_FORMAT_MODULE_CODE) + .build(); + + test.call_raw("test", &[]).unwrap(); + + test.assert_sql( + "SELECT * FROM t_ints", + r#" i8 | i16 | i32 | i64 | i128 | i256 +-----+-------+--------+----------+---------------+--------------- + -25 | -3224 | -23443 | -2344353 | -234434897853 | -234434897853"#, + ); + + test.assert_sql( + "SELECT * FROM t_ints_tuple", + r#" tuple +--------------------------------------------------------------------------------------------------- + (i8 = -25, i16 = -3224, i32 = -23443, i64 = -2344353, i128 = -234434897853, i256 = -234434897853)"#, + ); + + test.assert_sql( + "SELECT * FROM t_uints", + r#" u8 | u16 | u32 | u64 | u128 | u256 +-----+------+-------+----------+---------------+--------------- + 105 | 1050 | 83892 | 48937498 | 4378528978889 | 4378528978889"#, + ); + + test.assert_sql( + "SELECT * FROM t_uints_tuple", + r#" tuple +------------------------------------------------------------------------------------------------- + (u8 = 105, u16 = 1050, u32 = 83892, u64 = 48937498, u128 = 4378528978889, u256 = 4378528978889)"#, + ); + + test.assert_sql( + "SELECT * FROM t_others", + r#" bool | f32 | f64 | str | bytes | identity | connection_id | timestamp | duration | uuid +------+-----------+--------------------+-----------------------+------------------+--------------------------------------------------------------------+------------------------------------+---------------------------+-----------+---------------------------------------- + true | 594806.56 | -3454353.345389043 | "This is spacetimedb" | 0x01020304050607 | 0x0000000000000000000000000000000000000000000000000000000000000001 | 0x00000000000000000000000000000000 | 1970-01-01T00:00:00+00:00 | +0.000000 | "00000000-0000-0000-0000-000000000000""#, + ); + + test.assert_sql( + "SELECT * FROM t_others_tuple", + r#" tuple +---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + (bool = true, f32 = 594806.56, f64 = -3454353.345389043, str = "This is spacetimedb", bytes = 0x01020304050607, identity = 0x0000000000000000000000000000000000000000000000000000000000000001, connection_id = 0x00000000000000000000000000000000, timestamp = 1970-01-01T00:00:00+00:00, duration = +0.000000, uuid = "00000000-0000-0000-0000-000000000000")"#, + ); + + test.assert_sql( + "SELECT * FROM t_enums", + r#" bool_opt | bool_result | action +---------------+--------------+--------------- + (some = true) | (ok = false) | (Active = ())"#, + ); + + test.assert_sql( + "SELECT * FROM t_enums_tuple", + r#" tuple +-------------------------------------------------------------------------------- + (bool_opt = (some = true), bool_result = (ok = false), action = (Active = ()))"#, + ); +} From 8ddcdc915b63a2e1cbc3a3aa798e28ba56299ca4 Mon Sep 17 00:00:00 2001 From: = Date: Thu, 22 Jan 2026 22:35:58 -0500 Subject: [PATCH 003/118] Add more smoketest translations and simplify call API - Translate 4 more Python smoketests to Rust: auto_inc, describe, module_nested_op, panic - Simplify the call API by removing the generic call method and renaming call_raw to call, since CLI args are strings - Remove unused serde dependency --- Cargo.lock | 1 - crates/smoketests/Cargo.toml | 1 - crates/smoketests/src/lib.rs | 20 +-- crates/smoketests/tests/auto_inc.rs | 174 ++++++++++++++++++++ crates/smoketests/tests/call.rs | 4 +- crates/smoketests/tests/describe.rs | 44 +++++ crates/smoketests/tests/module_nested_op.rs | 63 +++++++ crates/smoketests/tests/panic.rs | 74 +++++++++ crates/smoketests/tests/sql.rs | 2 +- 9 files changed, 360 insertions(+), 23 deletions(-) create mode 100644 crates/smoketests/tests/auto_inc.rs create mode 100644 crates/smoketests/tests/describe.rs create mode 100644 crates/smoketests/tests/module_nested_op.rs create mode 100644 crates/smoketests/tests/panic.rs diff --git a/Cargo.lock b/Cargo.lock index f8e050114ff..71258da12d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8231,7 +8231,6 @@ dependencies = [ "anyhow", "cargo_metadata", "regex", - "serde", "serde_json", "spacetimedb-guard", "tempfile", diff --git a/crates/smoketests/Cargo.toml b/crates/smoketests/Cargo.toml index faad83892f0..1aa3d3ba8a6 100644 --- a/crates/smoketests/Cargo.toml +++ b/crates/smoketests/Cargo.toml @@ -8,7 +8,6 @@ rust-version.workspace = true # Test utilities (needed in lib for test helpers) spacetimedb-guard.workspace = true tempfile.workspace = true -serde.workspace = true serde_json.workspace = true toml.workspace = true regex.workspace = true diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 43b9410f77e..24b2f03df2b 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -35,7 +35,6 @@ use anyhow::{bail, Context, Result}; use regex::Regex; -use serde::Serialize; use spacetimedb_guard::{ensure_binaries_built, SpacetimeDbGuard}; use std::env; use std::fs; @@ -258,23 +257,8 @@ impl Smoketest { /// Calls a reducer or procedure with the given arguments. /// - /// Arguments are serialized to JSON. - pub fn call(&self, name: &str, args: &[T]) -> Result { - let identity = self - .database_identity - .as_ref() - .context("No database published")?; - - let mut cmd_args = vec!["call", "--", identity.as_str(), name]; - let json_args: Vec = args.iter().map(|a| serde_json::to_string(a).unwrap()).collect(); - let json_refs: Vec<&str> = json_args.iter().map(|s| s.as_str()).collect(); - cmd_args.extend(json_refs); - - self.spacetime(&cmd_args) - } - - /// Calls a reducer or procedure with raw string arguments (no JSON serialization). - pub fn call_raw(&self, name: &str, args: &[&str]) -> Result { + /// Arguments are passed directly to the CLI as strings. + pub fn call(&self, name: &str, args: &[&str]) -> Result { let identity = self .database_identity .as_ref() diff --git a/crates/smoketests/tests/auto_inc.rs b/crates/smoketests/tests/auto_inc.rs new file mode 100644 index 00000000000..cd4633e0ce3 --- /dev/null +++ b/crates/smoketests/tests/auto_inc.rs @@ -0,0 +1,174 @@ +//! Auto-increment tests translated from smoketests/tests/auto_inc.py +//! +//! This is a simplified version that tests representative integer types +//! rather than all 10 types in the Python version. + +use spacetimedb_smoketests::Smoketest; + +/// Generate module code for basic auto-increment test with a specific integer type +fn autoinc_basic_module_code(int_ty: &str) -> String { + format!( + r#" +#![allow(non_camel_case_types)] +use spacetimedb::{{log, ReducerContext, Table}}; + +#[spacetimedb::table(name = person_{int_ty})] +pub struct Person_{int_ty} {{ + #[auto_inc] + key_col: {int_ty}, + name: String, +}} + +#[spacetimedb::reducer] +pub fn add_{int_ty}(ctx: &ReducerContext, name: String, expected_value: {int_ty}) {{ + let value = ctx.db.person_{int_ty}().insert(Person_{int_ty} {{ key_col: 0, name }}); + assert_eq!(value.key_col, expected_value); +}} + +#[spacetimedb::reducer] +pub fn say_hello_{int_ty}(ctx: &ReducerContext) {{ + for person in ctx.db.person_{int_ty}().iter() {{ + log::info!("Hello, {{}}:{{}}!", person.key_col, person.name); + }} + log::info!("Hello, World!"); +}} +"# + ) +} + +fn do_test_autoinc_basic(int_ty: &str) { + let module_code = autoinc_basic_module_code(int_ty); + let test = Smoketest::builder().module_code(&module_code).build(); + + test.call(&format!("add_{}", int_ty), &[r#""Robert""#, "1"]).unwrap(); + test.call(&format!("add_{}", int_ty), &[r#""Julie""#, "2"]).unwrap(); + test.call(&format!("add_{}", int_ty), &[r#""Samantha""#, "3"]).unwrap(); + test.call(&format!("say_hello_{}", int_ty), &[]).unwrap(); + + let logs = test.logs(4).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 3:Samantha!")), + "Expected 'Hello, 3:Samantha!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 2:Julie!")), + "Expected 'Hello, 2:Julie!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 1:Robert!")), + "Expected 'Hello, 1:Robert!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, World!")), + "Expected 'Hello, World!' in logs, got: {:?}", + logs + ); +} + +#[test] +fn test_autoinc_u32() { + do_test_autoinc_basic("u32"); +} + +#[test] +fn test_autoinc_u64() { + do_test_autoinc_basic("u64"); +} + +#[test] +fn test_autoinc_i32() { + do_test_autoinc_basic("i32"); +} + +#[test] +fn test_autoinc_i64() { + do_test_autoinc_basic("i64"); +} + +/// Generate module code for auto-increment with unique constraint test +fn autoinc_unique_module_code(int_ty: &str) -> String { + format!( + r#" +#![allow(non_camel_case_types)] +use std::error::Error; +use spacetimedb::{{log, ReducerContext, Table}}; + +#[spacetimedb::table(name = person_{int_ty})] +pub struct Person_{int_ty} {{ + #[auto_inc] + #[unique] + key_col: {int_ty}, + #[unique] + name: String, +}} + +#[spacetimedb::reducer] +pub fn add_new_{int_ty}(ctx: &ReducerContext, name: String) -> Result<(), Box> {{ + let value = ctx.db.person_{int_ty}().try_insert(Person_{int_ty} {{ key_col: 0, name }})?; + log::info!("Assigned Value: {{}} -> {{}}", value.key_col, value.name); + Ok(()) +}} + +#[spacetimedb::reducer] +pub fn update_{int_ty}(ctx: &ReducerContext, name: String, new_id: {int_ty}) {{ + ctx.db.person_{int_ty}().name().delete(&name); + let _value = ctx.db.person_{int_ty}().insert(Person_{int_ty} {{ key_col: new_id, name }}); +}} + +#[spacetimedb::reducer] +pub fn say_hello_{int_ty}(ctx: &ReducerContext) {{ + for person in ctx.db.person_{int_ty}().iter() {{ + log::info!("Hello, {{}}:{{}}!", person.key_col, person.name); + }} + log::info!("Hello, World!"); +}} +"# + ) +} + +fn do_test_autoinc_unique(int_ty: &str) { + let module_code = autoinc_unique_module_code(int_ty); + let test = Smoketest::builder().module_code(&module_code).build(); + + // Insert Robert with explicit id 2 + test.call(&format!("update_{}", int_ty), &[r#""Robert""#, "2"]).unwrap(); + + // Auto-inc should assign id 1 to Success + test.call(&format!("add_new_{}", int_ty), &[r#""Success""#]).unwrap(); + + // Auto-inc tries to assign id 2, but Robert already has it - should fail + let result = test.call(&format!("add_new_{}", int_ty), &[r#""Failure""#]); + assert!(result.is_err(), "Expected add_new to fail due to unique constraint violation"); + + test.call(&format!("say_hello_{}", int_ty), &[]).unwrap(); + + let logs = test.logs(4).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 2:Robert!")), + "Expected 'Hello, 2:Robert!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 1:Success!")), + "Expected 'Hello, 1:Success!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, World!")), + "Expected 'Hello, World!' in logs, got: {:?}", + logs + ); +} + +#[test] +fn test_autoinc_unique_u64() { + do_test_autoinc_unique("u64"); +} + +#[test] +fn test_autoinc_unique_i64() { + do_test_autoinc_unique("i64"); +} diff --git a/crates/smoketests/tests/call.rs b/crates/smoketests/tests/call.rs index 0d8a917fd81..96b0bc80211 100644 --- a/crates/smoketests/tests/call.rs +++ b/crates/smoketests/tests/call.rs @@ -29,11 +29,11 @@ fn test_call_reducer_procedure() { .build(); // Reducer returns empty - let msg = test.call_raw("say_hello", &[]).unwrap(); + let msg = test.call("say_hello", &[]).unwrap(); assert_eq!(msg.trim(), ""); // Procedure returns a value - let msg = test.call_raw("return_person", &[]).unwrap(); + let msg = test.call("return_person", &[]).unwrap(); assert_eq!(msg.trim(), r#"["World"]"#); } diff --git a/crates/smoketests/tests/describe.rs b/crates/smoketests/tests/describe.rs new file mode 100644 index 00000000000..a31357c09f7 --- /dev/null +++ b/crates/smoketests/tests/describe.rs @@ -0,0 +1,44 @@ +//! Module description tests translated from smoketests/tests/describe.py + +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE: &str = r#" +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person)] +pub struct Person { + name: String, +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { name }); +} + +#[spacetimedb::reducer] +pub fn say_hello(ctx: &ReducerContext) { + for person in ctx.db.person().iter() { + log::info!("Hello, {}!", person.name); + } + log::info!("Hello, World!"); +} +"#; + +/// Check describing a module +#[test] +fn test_describe() { + let test = Smoketest::builder().module_code(MODULE_CODE).build(); + + let identity = test.database_identity.as_ref().unwrap(); + + // Describe the whole module + test.spacetime(&["describe", "--json", identity]).unwrap(); + + // Describe a specific reducer + test.spacetime(&["describe", "--json", identity, "reducer", "say_hello"]) + .unwrap(); + + // Describe a specific table + test.spacetime(&["describe", "--json", identity, "table", "person"]) + .unwrap(); +} diff --git a/crates/smoketests/tests/module_nested_op.rs b/crates/smoketests/tests/module_nested_op.rs new file mode 100644 index 00000000000..99d38a4acf0 --- /dev/null +++ b/crates/smoketests/tests/module_nested_op.rs @@ -0,0 +1,63 @@ +//! Nested table operation tests translated from smoketests/tests/module_nested_op.py + +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE: &str = r#" +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = account)] +pub struct Account { + name: String, + #[unique] + id: i32, +} + +#[spacetimedb::table(name = friends)] +pub struct Friends { + friend_1: i32, + friend_2: i32, +} + +#[spacetimedb::reducer] +pub fn create_account(ctx: &ReducerContext, account_id: i32, name: String) { + ctx.db.account().insert(Account { id: account_id, name } ); +} + +#[spacetimedb::reducer] +pub fn add_friend(ctx: &ReducerContext, my_id: i32, their_id: i32) { + // Make sure our friend exists + for account in ctx.db.account().iter() { + if account.id == their_id { + ctx.db.friends().insert(Friends { friend_1: my_id, friend_2: their_id }); + return; + } + } +} + +#[spacetimedb::reducer] +pub fn say_friends(ctx: &ReducerContext) { + for friendship in ctx.db.friends().iter() { + let friend1 = ctx.db.account().id().find(&friendship.friend_1).unwrap(); + let friend2 = ctx.db.account().id().find(&friendship.friend_2).unwrap(); + log::info!("{} is friends with {}", friend1.name, friend2.name); + } +} +"#; + +/// This tests uploading a basic module and calling some functions and checking logs afterwards. +#[test] +fn test_module_nested_op() { + let test = Smoketest::builder().module_code(MODULE_CODE).build(); + + test.call("create_account", &["1", r#""House""#]).unwrap(); + test.call("create_account", &["2", r#""Wilson""#]).unwrap(); + test.call("add_friend", &["1", "2"]).unwrap(); + test.call("say_friends", &[]).unwrap(); + + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("House is friends with Wilson")), + "Expected 'House is friends with Wilson' in logs, got: {:?}", + logs + ); +} diff --git a/crates/smoketests/tests/panic.rs b/crates/smoketests/tests/panic.rs new file mode 100644 index 00000000000..06681d9f3a9 --- /dev/null +++ b/crates/smoketests/tests/panic.rs @@ -0,0 +1,74 @@ +//! Panic and error handling tests translated from smoketests/tests/panic.py + +use spacetimedb_smoketests::Smoketest; + +const PANIC_MODULE_CODE: &str = r#" +use spacetimedb::{log, ReducerContext}; +use std::cell::RefCell; + +thread_local! { + static X: RefCell = RefCell::new(0); +} +#[spacetimedb::reducer] +fn first(_ctx: &ReducerContext) { + X.with(|x| { + let _x = x.borrow_mut(); + panic!() + }) +} +#[spacetimedb::reducer] +fn second(_ctx: &ReducerContext) { + X.with(|x| *x.borrow_mut()); + log::info!("Test Passed"); +} +"#; + +/// Tests to check if a SpacetimeDB module can handle a panic without corrupting +#[test] +fn test_panic() { + let test = Smoketest::builder() + .module_code(PANIC_MODULE_CODE) + .build(); + + // First reducer should panic/fail + let result = test.call("first", &[]); + assert!(result.is_err(), "Expected first reducer to fail due to panic"); + + // Second reducer should succeed, proving state wasn't corrupted + test.call("second", &[]).unwrap(); + + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("Test Passed")), + "Expected 'Test Passed' in logs, got: {:?}", + logs + ); +} + +const REDUCER_ERROR_MODULE_CODE: &str = r#" +use spacetimedb::ReducerContext; + +#[spacetimedb::reducer] +fn fail(_ctx: &ReducerContext) -> Result<(), String> { + Err("oopsie :(".into()) +} +"#; + +/// Tests to ensure an error message returned from a reducer gets printed to logs +#[test] +fn test_reducer_error_message() { + let test = Smoketest::builder() + .module_code(REDUCER_ERROR_MODULE_CODE) + .build(); + + // Reducer should fail with error + let result = test.call("fail", &[]); + assert!(result.is_err(), "Expected fail reducer to return error"); + + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("oopsie :(")), + "Expected 'oopsie :(' in logs, got: {:?}", + logs + ); +} diff --git a/crates/smoketests/tests/sql.rs b/crates/smoketests/tests/sql.rs index 0e8571959e3..c865aa3d468 100644 --- a/crates/smoketests/tests/sql.rs +++ b/crates/smoketests/tests/sql.rs @@ -134,7 +134,7 @@ fn test_sql_format() { .module_code(SQL_FORMAT_MODULE_CODE) .build(); - test.call_raw("test", &[]).unwrap(); + test.call("test", &[]).unwrap(); test.assert_sql( "SELECT * FROM t_ints", From 7cc8e9532d986b1eb3ebf0cecffafcb159a4c257 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 23 Jan 2026 00:15:43 -0500 Subject: [PATCH 004/118] Add 5 more smoketest translations and background subscription support Translate additional Python smoketests to Rust: - dml.rs: DML subscription tests - filtering.rs: Unique/non-unique index filtering tests - namespaces.rs: C# code generation namespace tests - add_remove_index.rs: Index add/remove with subscription tests - schedule_reducer.rs: Scheduled reducer tests Infrastructure improvements: - Add subscribe_background() and SubscriptionHandle for proper background subscription semantics matching Python tests - Add spacetime_local() for commands that don't need --server flag - Add timing instrumentation for debugging test performance --- crates/smoketests/src/lib.rs | 152 ++++++- crates/smoketests/tests/add_remove_index.rs | 98 +++++ crates/smoketests/tests/dml.rs | 43 ++ crates/smoketests/tests/filtering.rs | 463 ++++++++++++++++++++ crates/smoketests/tests/namespaces.rs | 137 ++++++ crates/smoketests/tests/schedule_reducer.rs | 181 ++++++++ 6 files changed, 1064 insertions(+), 10 deletions(-) create mode 100644 crates/smoketests/tests/add_remove_index.rs create mode 100644 crates/smoketests/tests/dml.rs create mode 100644 crates/smoketests/tests/filtering.rs create mode 100644 crates/smoketests/tests/namespaces.rs create mode 100644 crates/smoketests/tests/schedule_reducer.rs diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 24b2f03df2b..4f84dfc4e62 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -40,6 +40,18 @@ use std::env; use std::fs; use std::path::PathBuf; use std::process::{Command, Output, Stdio}; +use std::time::Instant; + +/// Helper macro for timing operations and printing results +macro_rules! timed { + ($label:expr, $expr:expr) => {{ + let start = Instant::now(); + let result = $expr; + let elapsed = start.elapsed(); + eprintln!("[TIMING] {}: {:?}", $label, elapsed); + result + }}; +} /// Returns the workspace root directory. fn workspace_root() -> PathBuf { @@ -118,9 +130,13 @@ impl SmoketestBuilder { /// This spawns a SpacetimeDB server, creates a temporary project directory, /// writes the module code, and optionally publishes the module. pub fn build(self) -> Smoketest { - let guard = SpacetimeDbGuard::spawn_in_temp_data_dir(); + let build_start = Instant::now(); + + let guard = timed!("server spawn", SpacetimeDbGuard::spawn_in_temp_data_dir()); let project_dir = tempfile::tempdir().expect("Failed to create temp project directory"); + let project_setup_start = Instant::now(); + // Create project structure fs::create_dir_all(project_dir.path().join("src")).expect("Failed to create src directory"); @@ -167,6 +183,8 @@ pub fn noop(_ctx: &ReducerContext) {} }); fs::write(project_dir.path().join("src/lib.rs"), &module_code).expect("Failed to write lib.rs"); + eprintln!("[TIMING] project setup: {:?}", project_setup_start.elapsed()); + let server_url = guard.host_url.clone(); let mut smoketest = Smoketest { guard, @@ -179,6 +197,7 @@ pub fn noop(_ctx: &ReducerContext) {} smoketest.publish_module().expect("Failed to publish module"); } + eprintln!("[TIMING] total build: {:?}", build_start.elapsed()); smoketest } } @@ -195,6 +214,7 @@ impl Smoketest { /// Returns the command output. The command is run but not yet asserted. /// The `--server` flag is automatically inserted after the first argument (the subcommand). pub fn spacetime_cmd(&self, args: &[&str]) -> Output { + let start = Instant::now(); let cli_path = ensure_binaries_built(); let mut cmd = Command::new(&cli_path); @@ -206,9 +226,13 @@ impl Smoketest { .args(rest); } - cmd.current_dir(self.project_dir.path()) + let output = cmd.current_dir(self.project_dir.path()) .output() - .expect("Failed to execute spacetime command") + .expect("Failed to execute spacetime command"); + + let cmd_name = args.first().unwrap_or(&"unknown"); + eprintln!("[TIMING] spacetime {}: {:?}", cmd_name, start.elapsed()); + output } /// Runs a spacetime CLI command and returns stdout as a string. @@ -227,6 +251,32 @@ impl Smoketest { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } + /// Runs a spacetime CLI command without adding the --server flag. + /// + /// Use this for local-only commands like `generate` that don't need a server connection. + pub fn spacetime_local(&self, args: &[&str]) -> Result { + let start = Instant::now(); + let cli_path = ensure_binaries_built(); + let output = Command::new(&cli_path) + .args(args) + .current_dir(self.project_dir.path()) + .output() + .expect("Failed to execute spacetime command"); + + let cmd_name = args.first().unwrap_or(&"unknown"); + eprintln!("[TIMING] spacetime_local {}: {:?}", cmd_name, start.elapsed()); + + if !output.status.success() { + bail!( + "spacetime {:?} failed:\nstdout: {}\nstderr: {}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + /// Writes new module code to the project. pub fn write_module_code(&self, code: &str) -> Result<()> { fs::write(self.project_dir.path().join("src/lib.rs"), code) @@ -236,13 +286,35 @@ impl Smoketest { /// Publishes the module and stores the database identity. pub fn publish_module(&mut self) -> Result { + self.publish_module_opts(None, false) + } + + /// Publishes the module with a specific name and optional clear flag. + /// + /// If `name` is provided, the database will be published with that name. + /// If `clear` is true, the database will be cleared before publishing. + pub fn publish_module_named(&mut self, name: &str, clear: bool) -> Result { + self.publish_module_opts(Some(name), clear) + } + + /// Internal helper for publishing with options. + fn publish_module_opts(&mut self, name: Option<&str>, clear: bool) -> Result { + let start = Instant::now(); let project_path = self.project_dir.path().to_str().unwrap().to_string(); - let output = self.spacetime(&[ - "publish", - "--project-path", - &project_path, - "--yes", - ])?; + let mut args = vec!["publish", "--project-path", &project_path, "--yes"]; + + if clear { + args.push("--clear-database"); + } + + let name_owned; + if let Some(n) = name { + name_owned = n.to_string(); + args.push(&name_owned); + } + + let output = self.spacetime(&args)?; + eprintln!("[TIMING] publish_module: {:?}", start.elapsed()); // Parse the identity from output like "identity: abc123..." let re = Regex::new(r"identity: ([0-9a-fA-F]+)").unwrap(); @@ -334,10 +406,12 @@ impl Smoketest { .collect() } - /// Starts a subscription and waits for N updates. + /// Starts a subscription and waits for N updates (synchronous). /// /// Returns the updates as JSON values. + /// For tests that need to perform actions while subscribed, use `subscribe_background` instead. pub fn subscribe(&self, queries: &[&str], n: usize) -> Result> { + let start = Instant::now(); let identity = self .database_identity .as_ref() @@ -351,6 +425,64 @@ impl Smoketest { .stderr(Stdio::piped()); let output = cmd.output().context("Failed to run subscribe command")?; + eprintln!("[TIMING] subscribe (n={}): {:?}", n, start.elapsed()); + + if !output.status.success() { + bail!( + "subscribe failed:\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).context("Failed to parse subscription update")) + .collect() + } + + /// Starts a subscription in the background and returns a handle. + /// + /// This matches Python's subscribe semantics - start subscription first, + /// perform actions, then call the handle to collect results. + pub fn subscribe_background(&self, queries: &[&str], n: usize) -> Result { + let identity = self + .database_identity + .as_ref() + .context("No database published")? + .clone(); + + let cli_path = ensure_binaries_built(); + let mut cmd = Command::new(&cli_path); + // Note: Don't use --print-initial-update here since we want to count only + // the actual updates triggered by subsequent operations + cmd.args(["subscribe", "--server", &self.server_url, &identity, "-t", "30", "-n", &n.to_string(), "--"]) + .args(queries) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let child = cmd.spawn().context("Failed to spawn subscribe command")?; + Ok(SubscriptionHandle { + child, + n, + start: Instant::now(), + }) + } +} + +/// Handle for a background subscription. +pub struct SubscriptionHandle { + child: std::process::Child, + n: usize, + start: Instant, +} + +impl SubscriptionHandle { + /// Wait for the subscription to complete and return the updates. + pub fn collect(self) -> Result> { + let output = self.child.wait_with_output().context("Failed to wait for subscribe command")?; + eprintln!("[TIMING] subscribe_background (n={}): {:?}", self.n, self.start.elapsed()); if !output.status.success() { bail!( diff --git a/crates/smoketests/tests/add_remove_index.rs b/crates/smoketests/tests/add_remove_index.rs new file mode 100644 index 00000000000..bc1512c92a4 --- /dev/null +++ b/crates/smoketests/tests/add_remove_index.rs @@ -0,0 +1,98 @@ +//! Add/remove index tests translated from smoketests/tests/add_remove_index.py + +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE: &str = r#" +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = t1)] +pub struct T1 { id: u64 } + +#[spacetimedb::table(name = t2)] +pub struct T2 { id: u64 } + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + for id in 0..1_000 { + ctx.db.t1().insert(T1 { id }); + ctx.db.t2().insert(T2 { id }); + } +} +"#; + +const MODULE_CODE_INDEXED: &str = r#" +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = t1)] +pub struct T1 { #[index(btree)] id: u64 } + +#[spacetimedb::table(name = t2)] +pub struct T2 { #[index(btree)] id: u64 } + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + for id in 0..1_000 { + ctx.db.t1().insert(T1 { id }); + ctx.db.t2().insert(T2 { id }); + } +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext) { + let id = 1_001; + ctx.db.t1().insert(T1 { id }); + ctx.db.t2().insert(T2 { id }); +} +"#; + +const JOIN_QUERY: &str = "select t1.* from t1 join t2 on t1.id = t2.id where t2.id = 1001"; + +/// First publish without the indices, +/// then add the indices, and publish, +/// and finally remove the indices, and publish again. +/// There should be no errors +/// and the unindexed versions should reject subscriptions. +#[test] +fn test_add_then_remove_index() { + let mut test = Smoketest::builder() + .module_code(MODULE_CODE) + .autopublish(false) + .build(); + + let name = format!("test-db-{}", std::process::id()); + + // Publish and attempt a subscribing to a join query. + // There are no indices, resulting in an unsupported unindexed join. + test.publish_module_named(&name, false).unwrap(); + let result = test.subscribe(&[JOIN_QUERY], 0); + assert!( + result.is_err(), + "Expected subscription to fail without indices" + ); + + // Publish the indexed version. + // Now we have indices, so the query should be accepted. + test.write_module_code(MODULE_CODE_INDEXED).unwrap(); + test.publish_module_named(&name, false).unwrap(); + + // Subscription should work now (n=0 just verifies the query is accepted) + let result = test.subscribe(&[JOIN_QUERY], 0); + assert!( + result.is_ok(), + "Expected subscription to succeed with indices, got: {:?}", + result.err() + ); + + // Verify call works too + test.call("add", &[]).unwrap(); + + // Publish the unindexed version again, removing the index. + // The initial subscription should be rejected again. + test.write_module_code(MODULE_CODE).unwrap(); + test.publish_module_named(&name, false).unwrap(); + let result = test.subscribe(&[JOIN_QUERY], 0); + assert!( + result.is_err(), + "Expected subscription to fail after removing indices" + ); +} diff --git a/crates/smoketests/tests/dml.rs b/crates/smoketests/tests/dml.rs new file mode 100644 index 00000000000..5458c0d9830 --- /dev/null +++ b/crates/smoketests/tests/dml.rs @@ -0,0 +1,43 @@ +//! DML tests translated from smoketests/tests/dml.py + +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE: &str = r#" +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = t, public)] +pub struct T { + name: String, +} +"#; + +/// Test that we receive subscription updates from DML +#[test] +fn test_subscribe() { + use std::thread; + use std::time::Duration; + + let test = Smoketest::builder().module_code(MODULE_CODE).build(); + + // Start subscription FIRST (in background), matching Python semantics + let sub = test.subscribe_background(&["SELECT * FROM t"], 2).unwrap(); + + // Small delay to ensure subscription is connected before inserts + thread::sleep(Duration::from_millis(500)); + + // Then do the SQL inserts while subscription is running + test.sql("INSERT INTO t (name) VALUES ('Alice')").unwrap(); + test.sql("INSERT INTO t (name) VALUES ('Bob')").unwrap(); + + // Collect the subscription results + let updates = sub.collect().unwrap(); + + assert_eq!( + updates, + vec![ + serde_json::json!({"t": {"deletes": [], "inserts": [{"name": "Alice"}]}}), + serde_json::json!({"t": {"deletes": [], "inserts": [{"name": "Bob"}]}}), + ], + "Expected subscription updates for Alice and Bob inserts" + ); +} diff --git a/crates/smoketests/tests/filtering.rs b/crates/smoketests/tests/filtering.rs new file mode 100644 index 00000000000..8f891d3f517 --- /dev/null +++ b/crates/smoketests/tests/filtering.rs @@ -0,0 +1,463 @@ +//! Filtering tests translated from smoketests/tests/filtering.py + +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE: &str = r#" +use spacetimedb::{log, Identity, ReducerContext, Table}; + +#[spacetimedb::table(name = person)] +pub struct Person { + #[unique] + id: i32, + + name: String, + + #[unique] + nick: String, +} + +#[spacetimedb::reducer] +pub fn insert_person(ctx: &ReducerContext, id: i32, name: String, nick: String) { + ctx.db.person().insert(Person { id, name, nick} ); +} + +#[spacetimedb::reducer] +pub fn insert_person_twice(ctx: &ReducerContext, id: i32, name: String, nick: String) { + // We'd like to avoid an error due to a set-semantic error. + let name2 = format!("{name}2"); + ctx.db.person().insert(Person { id, name, nick: nick.clone()} ); + match ctx.db.person().try_insert(Person { id, name: name2, nick: nick.clone()}) { + Ok(_) => {}, + Err(_) => { + log::info!("UNIQUE CONSTRAINT VIOLATION ERROR: id = {}, nick = {}", id, nick) + } + } +} + +#[spacetimedb::reducer] +pub fn delete_person(ctx: &ReducerContext, id: i32) { + ctx.db.person().id().delete(&id); +} + +#[spacetimedb::reducer] +pub fn find_person(ctx: &ReducerContext, id: i32) { + match ctx.db.person().id().find(&id) { + Some(person) => log::info!("UNIQUE FOUND: id {}: {}", id, person.name), + None => log::info!("UNIQUE NOT FOUND: id {}", id), + } +} + +#[spacetimedb::reducer] +pub fn find_person_read_only(ctx: &ReducerContext, id: i32) { + let ctx = ctx.as_read_only(); + match ctx.db.person().id().find(&id) { + Some(person) => log::info!("UNIQUE FOUND: id {}: {}", id, person.name), + None => log::info!("UNIQUE NOT FOUND: id {}", id), + } +} + +#[spacetimedb::reducer] +pub fn find_person_by_name(ctx: &ReducerContext, name: String) { + for person in ctx.db.person().iter().filter(|p| p.name == name) { + log::info!("UNIQUE FOUND: id {}: {} aka {}", person.id, person.name, person.nick); + } +} + +#[spacetimedb::reducer] +pub fn find_person_by_nick(ctx: &ReducerContext, nick: String) { + match ctx.db.person().nick().find(&nick) { + Some(person) => log::info!("UNIQUE FOUND: id {}: {}", person.id, person.nick), + None => log::info!("UNIQUE NOT FOUND: nick {}", nick), + } +} + +#[spacetimedb::reducer] +pub fn find_person_by_nick_read_only(ctx: &ReducerContext, nick: String) { + let ctx = ctx.as_read_only(); + match ctx.db.person().nick().find(&nick) { + Some(person) => log::info!("UNIQUE FOUND: id {}: {}", person.id, person.nick), + None => log::info!("UNIQUE NOT FOUND: nick {}", nick), + } +} + +#[spacetimedb::table(name = nonunique_person)] +pub struct NonuniquePerson { + #[index(btree)] + id: i32, + name: String, + is_human: bool, +} + +#[spacetimedb::reducer] +pub fn insert_nonunique_person(ctx: &ReducerContext, id: i32, name: String, is_human: bool) { + ctx.db.nonunique_person().insert(NonuniquePerson { id, name, is_human } ); +} + +#[spacetimedb::reducer] +pub fn find_nonunique_person(ctx: &ReducerContext, id: i32) { + for person in ctx.db.nonunique_person().id().filter(&id) { + log::info!("NONUNIQUE FOUND: id {}: {}", id, person.name) + } +} + +#[spacetimedb::reducer] +pub fn find_nonunique_person_read_only(ctx: &ReducerContext, id: i32) { + let ctx = ctx.as_read_only(); + for person in ctx.db.nonunique_person().id().filter(&id) { + log::info!("NONUNIQUE FOUND: id {}: {}", id, person.name) + } +} + +#[spacetimedb::reducer] +pub fn find_nonunique_humans(ctx: &ReducerContext) { + for person in ctx.db.nonunique_person().iter().filter(|p| p.is_human) { + log::info!("HUMAN FOUND: id {}: {}", person.id, person.name); + } +} + +#[spacetimedb::reducer] +pub fn find_nonunique_non_humans(ctx: &ReducerContext) { + for person in ctx.db.nonunique_person().iter().filter(|p| !p.is_human) { + log::info!("NON-HUMAN FOUND: id {}: {}", person.id, person.name); + } +} + +// Ensure that [Identity] is filterable and a legal unique column. +#[spacetimedb::table(name = identified_person)] +struct IdentifiedPerson { + #[unique] + identity: Identity, + name: String, +} + +fn identify(id_number: u64) -> Identity { + let mut bytes = [0u8; 32]; + bytes[..8].clone_from_slice(&id_number.to_le_bytes()); + Identity::from_byte_array(bytes) +} + +#[spacetimedb::reducer] +fn insert_identified_person(ctx: &ReducerContext, id_number: u64, name: String) { + let identity = identify(id_number); + ctx.db.identified_person().insert(IdentifiedPerson { identity, name }); +} + +#[spacetimedb::reducer] +fn find_identified_person(ctx: &ReducerContext, id_number: u64) { + let identity = identify(id_number); + match ctx.db.identified_person().identity().find(&identity) { + Some(person) => log::info!("IDENTIFIED FOUND: {}", person.name), + None => log::info!("IDENTIFIED NOT FOUND"), + } +} + +// Ensure that indices on non-unique columns behave as we expect. +#[spacetimedb::table(name = indexed_person)] +struct IndexedPerson { + #[unique] + id: i32, + given_name: String, + #[index(btree)] + surname: String, +} + +#[spacetimedb::reducer] +fn insert_indexed_person(ctx: &ReducerContext, id: i32, given_name: String, surname: String) { + ctx.db.indexed_person().insert(IndexedPerson { id, given_name, surname }); +} + +#[spacetimedb::reducer] +fn delete_indexed_person(ctx: &ReducerContext, id: i32) { + ctx.db.indexed_person().id().delete(&id); +} + +#[spacetimedb::reducer] +fn find_indexed_people(ctx: &ReducerContext, surname: String) { + for person in ctx.db.indexed_person().surname().filter(&surname) { + log::info!("INDEXED FOUND: id {}: {}, {}", person.id, person.surname, person.given_name); + } +} + +#[spacetimedb::reducer] +fn find_indexed_people_read_only(ctx: &ReducerContext, surname: String) { + let ctx = ctx.as_read_only(); + for person in ctx.db.indexed_person().surname().filter(&surname) { + log::info!("INDEXED FOUND: id {}: {}, {}", person.id, person.surname, person.given_name); + } +} +"#; + +/// Test filtering reducers +#[test] +fn test_filtering() { + let test = Smoketest::builder().module_code(MODULE_CODE).build(); + + test.call("insert_person", &["23", r#""Alice""#, r#""al""#]).unwrap(); + test.call("insert_person", &["42", r#""Bob""#, r#""bo""#]).unwrap(); + test.call("insert_person", &["64", r#""Bob""#, r#""b2""#]).unwrap(); + + // Find a person who is there. + test.call("find_person", &["23"]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("UNIQUE FOUND: id 23: Alice")), + "Expected 'UNIQUE FOUND: id 23: Alice' in logs, got: {:?}", + logs + ); + + // Find persons with the same name. + test.call("find_person_by_name", &[r#""Bob""#]).unwrap(); + let logs = test.logs(4).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("UNIQUE FOUND: id 42: Bob aka bo")), + "Expected 'UNIQUE FOUND: id 42: Bob aka bo' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("UNIQUE FOUND: id 64: Bob aka b2")), + "Expected 'UNIQUE FOUND: id 64: Bob aka b2' in logs, got: {:?}", + logs + ); + + // Fail to find a person who is not there. + test.call("find_person", &["43"]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("UNIQUE NOT FOUND: id 43")), + "Expected 'UNIQUE NOT FOUND: id 43' in logs, got: {:?}", + logs + ); + test.call("find_person_read_only", &["43"]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("UNIQUE NOT FOUND: id 43")), + "Expected 'UNIQUE NOT FOUND: id 43' in logs, got: {:?}", + logs + ); + + // Find a person by nickname. + test.call("find_person_by_nick", &[r#""al""#]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("UNIQUE FOUND: id 23: al")), + "Expected 'UNIQUE FOUND: id 23: al' in logs, got: {:?}", + logs + ); + test.call("find_person_by_nick_read_only", &[r#""al""#]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("UNIQUE FOUND: id 23: al")), + "Expected 'UNIQUE FOUND: id 23: al' in logs, got: {:?}", + logs + ); + + // Remove a person, and then fail to find them. + test.call("delete_person", &["23"]).unwrap(); + test.call("find_person", &["23"]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("UNIQUE NOT FOUND: id 23")), + "Expected 'UNIQUE NOT FOUND: id 23' in logs, got: {:?}", + logs + ); + test.call("find_person_read_only", &["23"]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("UNIQUE NOT FOUND: id 23")), + "Expected 'UNIQUE NOT FOUND: id 23' in logs, got: {:?}", + logs + ); + // Also fail by nickname + test.call("find_person_by_nick", &[r#""al""#]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("UNIQUE NOT FOUND: nick al")), + "Expected 'UNIQUE NOT FOUND: nick al' in logs, got: {:?}", + logs + ); + test.call("find_person_by_nick_read_only", &[r#""al""#]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("UNIQUE NOT FOUND: nick al")), + "Expected 'UNIQUE NOT FOUND: nick al' in logs, got: {:?}", + logs + ); + + // Add some nonunique people. + test.call("insert_nonunique_person", &["23", r#""Alice""#, "true"]).unwrap(); + test.call("insert_nonunique_person", &["42", r#""Bob""#, "true"]).unwrap(); + + // Find a nonunique person who is there. + test.call("find_nonunique_person", &["23"]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("NONUNIQUE FOUND: id 23: Alice")), + "Expected 'NONUNIQUE FOUND: id 23: Alice' in logs, got: {:?}", + logs + ); + test.call("find_nonunique_person_read_only", &["23"]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("NONUNIQUE FOUND: id 23: Alice")), + "Expected 'NONUNIQUE FOUND: id 23: Alice' in logs, got: {:?}", + logs + ); + + // Fail to find a nonunique person who is not there. + test.call("find_nonunique_person", &["43"]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + !logs.iter().any(|msg| msg.contains("NONUNIQUE NOT FOUND: id 43")), + "Expected no 'NONUNIQUE NOT FOUND: id 43' in logs, got: {:?}", + logs + ); + test.call("find_nonunique_person_read_only", &["43"]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + !logs.iter().any(|msg| msg.contains("NONUNIQUE NOT FOUND: id 43")), + "Expected no 'NONUNIQUE NOT FOUND: id 43' in logs, got: {:?}", + logs + ); + + // Insert a non-human, then find humans, then find non-humans + test.call("insert_nonunique_person", &["64", r#""Jibbitty""#, "false"]).unwrap(); + test.call("find_nonunique_humans", &[]).unwrap(); + let logs = test.logs(4).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("HUMAN FOUND: id 23: Alice")), + "Expected 'HUMAN FOUND: id 23: Alice' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("HUMAN FOUND: id 42: Bob")), + "Expected 'HUMAN FOUND: id 42: Bob' in logs, got: {:?}", + logs + ); + test.call("find_nonunique_non_humans", &[]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("NON-HUMAN FOUND: id 64: Jibbitty")), + "Expected 'NON-HUMAN FOUND: id 64: Jibbitty' in logs, got: {:?}", + logs + ); + + // Add another person with the same id, and find them both. + test.call("insert_nonunique_person", &["23", r#""Claire""#, "true"]).unwrap(); + test.call("find_nonunique_person", &["23"]).unwrap(); + let logs = test.logs(4).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("NONUNIQUE FOUND: id 23: Alice")), + "Expected 'NONUNIQUE FOUND: id 23: Alice' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("NONUNIQUE FOUND: id 23: Claire")), + "Expected 'NONUNIQUE FOUND: id 23: Claire' in logs, got: {:?}", + logs + ); + test.call("find_nonunique_person_read_only", &["23"]).unwrap(); + let logs = test.logs(4).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("NONUNIQUE FOUND: id 23: Alice")), + "Expected 'NONUNIQUE FOUND: id 23: Alice' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("NONUNIQUE FOUND: id 23: Claire")), + "Expected 'NONUNIQUE FOUND: id 23: Claire' in logs, got: {:?}", + logs + ); + + // Check for issues with things present in index but not DB + test.call("insert_person", &["101", r#""Fee""#, r#""fee""#]).unwrap(); + test.call("insert_person", &["102", r#""Fi""#, r#""fi""#]).unwrap(); + test.call("insert_person", &["103", r#""Fo""#, r#""fo""#]).unwrap(); + test.call("insert_person", &["104", r#""Fum""#, r#""fum""#]).unwrap(); + test.call("delete_person", &["103"]).unwrap(); + test.call("find_person", &["104"]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("UNIQUE FOUND: id 104: Fum")), + "Expected 'UNIQUE FOUND: id 104: Fum' in logs, got: {:?}", + logs + ); + test.call("find_person_read_only", &["104"]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("UNIQUE FOUND: id 104: Fum")), + "Expected 'UNIQUE FOUND: id 104: Fum' in logs, got: {:?}", + logs + ); + + // As above, but for non-unique indices: check for consistency between index and DB + test.call("insert_indexed_person", &["7", r#""James""#, r#""Bond""#]).unwrap(); + test.call("insert_indexed_person", &["79", r#""Gold""#, r#""Bond""#]).unwrap(); + test.call("insert_indexed_person", &["1", r#""Hydrogen""#, r#""Bond""#]).unwrap(); + test.call("insert_indexed_person", &["100", r#""Whiskey""#, r#""Bond""#]).unwrap(); + test.call("delete_indexed_person", &["100"]).unwrap(); + test.call("find_indexed_people", &[r#""Bond""#]).unwrap(); + let logs = test.logs(10).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("INDEXED FOUND: id 7: Bond, James")), + "Expected 'INDEXED FOUND: id 7: Bond, James' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("INDEXED FOUND: id 79: Bond, Gold")), + "Expected 'INDEXED FOUND: id 79: Bond, Gold' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("INDEXED FOUND: id 1: Bond, Hydrogen")), + "Expected 'INDEXED FOUND: id 1: Bond, Hydrogen' in logs, got: {:?}", + logs + ); + assert!( + !logs.iter().any(|msg| msg.contains("INDEXED FOUND: id 100: Bond, Whiskey")), + "Expected no 'INDEXED FOUND: id 100: Bond, Whiskey' in logs, got: {:?}", + logs + ); + test.call("find_indexed_people_read_only", &[r#""Bond""#]).unwrap(); + let logs = test.logs(10).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("INDEXED FOUND: id 7: Bond, James")), + "Expected 'INDEXED FOUND: id 7: Bond, James' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("INDEXED FOUND: id 79: Bond, Gold")), + "Expected 'INDEXED FOUND: id 79: Bond, Gold' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("INDEXED FOUND: id 1: Bond, Hydrogen")), + "Expected 'INDEXED FOUND: id 1: Bond, Hydrogen' in logs, got: {:?}", + logs + ); + assert!( + !logs.iter().any(|msg| msg.contains("INDEXED FOUND: id 100: Bond, Whiskey")), + "Expected no 'INDEXED FOUND: id 100: Bond, Whiskey' in logs, got: {:?}", + logs + ); + + // Filter by Identity + test.call("insert_identified_person", &["23", r#""Alice""#]).unwrap(); + test.call("find_identified_person", &["23"]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("IDENTIFIED FOUND: Alice")), + "Expected 'IDENTIFIED FOUND: Alice' in logs, got: {:?}", + logs + ); + + // Inserting into a table with unique constraints fails + // when the second row has the same value in the constrained columns as the first row. + // In this case, the table has `#[unique] id` and `#[unique] nick` but not `#[unique] name`. + test.call("insert_person_twice", &["23", r#""Alice""#, r#""al""#]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("UNIQUE CONSTRAINT VIOLATION ERROR: id = 23, nick = al")), + "Expected 'UNIQUE CONSTRAINT VIOLATION ERROR: id = 23, nick = al' in logs, got: {:?}", + logs + ); +} diff --git a/crates/smoketests/tests/namespaces.rs b/crates/smoketests/tests/namespaces.rs new file mode 100644 index 00000000000..367cbe5edd3 --- /dev/null +++ b/crates/smoketests/tests/namespaces.rs @@ -0,0 +1,137 @@ +//! Namespace tests translated from smoketests/tests/namespaces.py + +use spacetimedb_smoketests::Smoketest; +use std::fs; +use std::path::Path; + +/// Template module code matching the Python test's default +const TEMPLATE_MODULE_CODE: &str = r#" +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = person, public)] +pub struct Person { + name: String, +} + +#[spacetimedb::reducer(init)] +pub fn init(_ctx: &ReducerContext) { + // Called when the module is initially published +} + +#[spacetimedb::reducer(client_connected)] +pub fn identity_connected(_ctx: &ReducerContext) { + // Called everytime a new client connects +} + +#[spacetimedb::reducer(client_disconnected)] +pub fn identity_disconnected(_ctx: &ReducerContext) { + // Called everytime a client disconnects +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { name }); +} + +#[spacetimedb::reducer] +pub fn say_hello(ctx: &ReducerContext) { + for person in ctx.db.person().iter() { + log::info!("Hello, {}!", person.name); + } + log::info!("Hello, World!"); +} +"#; + +/// Count occurrences of a needle string in all .cs files under a directory +fn count_matches(dir: &Path, needle: &str) -> usize { + let mut count = 0; + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + count += count_matches(&path, needle); + } else if path.extension().map_or(false, |ext| ext == "cs") { + if let Ok(contents) = fs::read_to_string(&path) { + count += contents.matches(needle).count(); + } + } + } + } + count +} + +/// Ensure that the default namespace is working properly +#[test] +fn test_spacetimedb_ns_csharp() { + let test = Smoketest::builder() + .module_code(TEMPLATE_MODULE_CODE) + .autopublish(false) + .build(); + + let tmpdir = tempfile::tempdir().expect("Failed to create temp dir"); + let project_path = test.project_dir.path().to_str().unwrap(); + + // Use spacetime_local since generate doesn't need a server connection + test.spacetime_local(&[ + "generate", + "--out-dir", + tmpdir.path().to_str().unwrap(), + "--lang=csharp", + "--project-path", + project_path, + ]) + .unwrap(); + + let namespace = "SpacetimeDB.Types"; + assert_eq!( + count_matches(tmpdir.path(), &format!("namespace {}", namespace)), + 7, + "Expected 7 occurrences of 'namespace {}'", + namespace + ); + assert_eq!( + count_matches(tmpdir.path(), "using SpacetimeDB;"), + 0, + "Expected 0 occurrences of 'using SpacetimeDB;'" + ); +} + +/// Ensure that when a custom namespace is specified on the command line, it actually gets used in generation +#[test] +fn test_custom_ns_csharp() { + let test = Smoketest::builder() + .module_code(TEMPLATE_MODULE_CODE) + .autopublish(false) + .build(); + + let tmpdir = tempfile::tempdir().expect("Failed to create temp dir"); + let project_path = test.project_dir.path().to_str().unwrap(); + + // Use a unique namespace name + let namespace = "CustomTestNamespace"; + + // Use spacetime_local since generate doesn't need a server connection + test.spacetime_local(&[ + "generate", + "--out-dir", + tmpdir.path().to_str().unwrap(), + "--lang=csharp", + "--namespace", + namespace, + "--project-path", + project_path, + ]) + .unwrap(); + + assert_eq!( + count_matches(tmpdir.path(), &format!("namespace {}", namespace)), + 7, + "Expected 7 occurrences of 'namespace {}'", + namespace + ); + assert_eq!( + count_matches(tmpdir.path(), "using SpacetimeDB;"), + 7, + "Expected 7 occurrences of 'using SpacetimeDB;'" + ); +} diff --git a/crates/smoketests/tests/schedule_reducer.rs b/crates/smoketests/tests/schedule_reducer.rs new file mode 100644 index 00000000000..a17caace609 --- /dev/null +++ b/crates/smoketests/tests/schedule_reducer.rs @@ -0,0 +1,181 @@ +//! Scheduled reducer tests translated from smoketests/tests/schedule_reducer.py + +use spacetimedb_smoketests::Smoketest; +use std::thread; +use std::time::Duration; + +const CANCEL_REDUCER_MODULE_CODE: &str = r#" +use spacetimedb::{duration, log, ReducerContext, Table}; + +#[spacetimedb::reducer(init)] +fn init(ctx: &ReducerContext) { + let schedule = ctx.db.scheduled_reducer_args().insert(ScheduledReducerArgs { + num: 1, + scheduled_id: 0, + scheduled_at: duration!(100ms).into(), + }); + ctx.db.scheduled_reducer_args().scheduled_id().delete(&schedule.scheduled_id); + + let schedule = ctx.db.scheduled_reducer_args().insert(ScheduledReducerArgs { + num: 2, + scheduled_id: 0, + scheduled_at: duration!(1000ms).into(), + }); + do_cancel(ctx, schedule.scheduled_id); +} + +#[spacetimedb::table(name = scheduled_reducer_args, public, scheduled(reducer))] +pub struct ScheduledReducerArgs { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + scheduled_at: spacetimedb::ScheduleAt, + num: i32, +} + +#[spacetimedb::reducer] +fn do_cancel(ctx: &ReducerContext, schedule_id: u64) { + ctx.db.scheduled_reducer_args().scheduled_id().delete(&schedule_id); +} + +#[spacetimedb::reducer] +fn reducer(_ctx: &ReducerContext, args: ScheduledReducerArgs) { + log::info!("the reducer ran: {}", args.num); +} +"#; + +/// Ensure cancelling a reducer works +#[test] +fn test_cancel_reducer() { + let test = Smoketest::builder() + .module_code(CANCEL_REDUCER_MODULE_CODE) + .build(); + + // Wait for any scheduled reducers to potentially run + thread::sleep(Duration::from_secs(2)); + + let logs = test.logs(5).unwrap(); + let logs_str = logs.join("\n"); + assert!( + !logs_str.contains("the reducer ran"), + "Expected no 'the reducer ran' in logs, got: {:?}", + logs + ); +} + +const SUBSCRIBE_SCHEDULED_TABLE_MODULE_CODE: &str = r#" +use spacetimedb::{log, duration, ReducerContext, Table, Timestamp}; + +#[spacetimedb::table(name = scheduled_table, public, scheduled(my_reducer, at = sched_at))] +pub struct ScheduledTable { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + sched_at: spacetimedb::ScheduleAt, + prev: Timestamp, +} + +#[spacetimedb::reducer] +fn schedule_reducer(ctx: &ReducerContext) { + ctx.db.scheduled_table().insert(ScheduledTable { prev: Timestamp::from_micros_since_unix_epoch(0), scheduled_id: 2, sched_at: Timestamp::from_micros_since_unix_epoch(0).into(), }); +} + +#[spacetimedb::reducer] +fn schedule_repeated_reducer(ctx: &ReducerContext) { + ctx.db.scheduled_table().insert(ScheduledTable { prev: Timestamp::from_micros_since_unix_epoch(0), scheduled_id: 1, sched_at: duration!(100ms).into(), }); +} + +#[spacetimedb::reducer] +pub fn my_reducer(ctx: &ReducerContext, arg: ScheduledTable) { + log::info!("Invoked: ts={:?}, delta={:?}", ctx.timestamp, ctx.timestamp.duration_since(arg.prev)); +} +"#; + +/// Test deploying a module with a scheduled reducer and check if client receives +/// subscription update for scheduled table entry and deletion of reducer once it ran +#[test] +fn test_scheduled_table_subscription() { + let test = Smoketest::builder() + .module_code(SUBSCRIBE_SCHEDULED_TABLE_MODULE_CODE) + .build(); + + // Call a reducer to schedule a reducer (runs immediately since timestamp is 0) + test.call("schedule_reducer", &[]).unwrap(); + + // Wait for the scheduled reducer to run + thread::sleep(Duration::from_secs(2)); + + let logs = test.logs(100).unwrap(); + let invoked_count = logs.iter().filter(|line| line.contains("Invoked:")).count(); + assert_eq!( + invoked_count, 1, + "Expected scheduled reducer to run exactly once, but it ran {} times. Logs: {:?}", + invoked_count, logs + ); +} + +/// Test that repeated reducers run multiple times +#[test] +fn test_scheduled_table_subscription_repeated_reducer() { + let test = Smoketest::builder() + .module_code(SUBSCRIBE_SCHEDULED_TABLE_MODULE_CODE) + .build(); + + // Call a reducer to schedule a repeated reducer + test.call("schedule_repeated_reducer", &[]).unwrap(); + + // Wait for the scheduled reducer to run multiple times + thread::sleep(Duration::from_secs(2)); + + let logs = test.logs(100).unwrap(); + let invoked_count = logs.iter().filter(|line| line.contains("Invoked:")).count(); + assert!( + invoked_count > 2, + "Expected repeated reducer to run more than twice, but it ran {} times. Logs: {:?}", + invoked_count, logs + ); +} + +const VOLATILE_NONATOMIC_MODULE_CODE: &str = r#" +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = my_table, public)] +pub struct MyTable { + x: String, +} + +#[spacetimedb::reducer] +fn do_schedule(_ctx: &ReducerContext) { + spacetimedb::volatile_nonatomic_schedule_immediate!(do_insert("hello".to_owned())); +} + +#[spacetimedb::reducer] +fn do_insert(ctx: &ReducerContext, x: String) { + ctx.db.my_table().insert(MyTable { x }); +} +"#; + +/// Check that volatile_nonatomic_schedule_immediate works +#[test] +fn test_volatile_nonatomic_schedule_immediate() { + let test = Smoketest::builder() + .module_code(VOLATILE_NONATOMIC_MODULE_CODE) + .build(); + + // Insert directly first + test.call("do_insert", &[r#""yay!""#]).unwrap(); + + // Schedule another insert + test.call("do_schedule", &[]).unwrap(); + + // Wait a moment for the scheduled insert to complete + thread::sleep(Duration::from_millis(500)); + + // Query the table to verify both inserts happened + let result = test.sql("SELECT * FROM my_table").unwrap(); + assert!( + result.contains("yay!") && result.contains("hello"), + "Expected both 'yay!' and 'hello' in table, got: {}", + result + ); +} From d830e22932b305d53fa8daf3a7e726816e49df91 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 23 Jan 2026 00:59:18 -0500 Subject: [PATCH 005/118] Add timing breakdown and DEVELOP.md for smoketests - Separate build and publish timing in lib.rs to identify bottlenecks - Use --bin-path to skip redundant rebuild during publish - Add DEVELOP.md explaining cargo-nextest for faster test runs Timing breakdown per test: - WASM build: ~12s (75%) - Server publish: ~2s (12%) - Server spawn: ~2s (12%) cargo-nextest runs all test binaries in parallel, reducing total runtime from ~265s to ~160s (40% faster). --- crates/smoketests/DEVELOP.md | 69 ++++++++++++++++++++++++++++++++++++ crates/smoketests/src/lib.rs | 33 +++++++++++++++-- 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 crates/smoketests/DEVELOP.md diff --git a/crates/smoketests/DEVELOP.md b/crates/smoketests/DEVELOP.md new file mode 100644 index 00000000000..0fe3d6e7eb3 --- /dev/null +++ b/crates/smoketests/DEVELOP.md @@ -0,0 +1,69 @@ +# Smoketests Development Guide + +## Running Tests + +### Recommended: cargo-nextest + +For faster test execution, use [cargo-nextest](https://nexte.st/): + +```bash +# Install (one-time) +cargo install cargo-nextest --locked + +# Run all smoketests +cargo nextest run -p spacetimedb-smoketests + +# Run a specific test +cargo nextest run -p spacetimedb-smoketests test_sql_format +``` + +**Why nextest?** Standard `cargo test` compiles each test file in `tests/` as a separate binary and runs them sequentially. Nextest runs all test binaries in parallel, reducing total runtime by ~40% (160s vs 265s for 25 tests). + +### Alternative: cargo test + +Standard `cargo test` also works: + +```bash +cargo test -p spacetimedb-smoketests +``` + +Tests within each file run in parallel, but files run sequentially. + +## Test Performance + +Each test takes ~15-20s due to: +- **WASM compilation** (~12s): Each test compiles a fresh Rust module to WASM +- **Server spawn** (~2s): Each test starts its own SpacetimeDB server +- **Module publish** (~2s): Server processes and initializes the WASM module + +When running tests in parallel, resource contention increases individual test times but reduces overall runtime. + +## Writing Tests + +See existing tests for patterns. Key points: + +```rust +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE: &str = r#" +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = example, public)] +pub struct Example { value: u64 } + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext, value: u64) { + ctx.db.example().insert(Example { value }); +} +"#; + +#[test] +fn test_example() { + let test = Smoketest::builder() + .module_code(MODULE_CODE) + .build(); + + test.call("add", &["42"]).unwrap(); + test.assert_sql("SELECT * FROM example", "value\n-----\n42"); +} +``` diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 4f84dfc4e62..62087b92220 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -301,7 +301,35 @@ impl Smoketest { fn publish_module_opts(&mut self, name: Option<&str>, clear: bool) -> Result { let start = Instant::now(); let project_path = self.project_dir.path().to_str().unwrap().to_string(); - let mut args = vec!["publish", "--project-path", &project_path, "--yes"]; + + // First, run spacetime build to compile the WASM module (separate from publish) + let build_start = Instant::now(); + let cli_path = ensure_binaries_built(); + let build_output = Command::new(&cli_path) + .args(["build", "--project-path", &project_path]) + .current_dir(self.project_dir.path()) + .output() + .expect("Failed to execute spacetime build"); + eprintln!("[TIMING] spacetime build: {:?}", build_start.elapsed()); + + if !build_output.status.success() { + bail!( + "spacetime build failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&build_output.stdout), + String::from_utf8_lossy(&build_output.stderr) + ); + } + + // Construct the wasm path (module name is smoketest-module -> smoketest_module.wasm) + let wasm_path = self + .project_dir + .path() + .join("target/wasm32-unknown-unknown/release/smoketest_module.wasm"); + let wasm_path_str = wasm_path.to_str().unwrap().to_string(); + + // Now publish with --bin-path to skip rebuild + let publish_start = Instant::now(); + let mut args = vec!["publish", "--bin-path", &wasm_path_str, "--yes"]; if clear { args.push("--clear-database"); @@ -314,7 +342,8 @@ impl Smoketest { } let output = self.spacetime(&args)?; - eprintln!("[TIMING] publish_module: {:?}", start.elapsed()); + eprintln!("[TIMING] spacetime publish (after build): {:?}", publish_start.elapsed()); + eprintln!("[TIMING] publish_module total: {:?}", start.elapsed()); // Parse the identity from output like "identity: abc123..." let re = Regex::new(r"identity: ([0-9a-fA-F]+)").unwrap(); From b3de380667fb1a95ca996db0765d55cd7a8c6832 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 23 Jan 2026 01:10:23 -0500 Subject: [PATCH 006/118] Add 5 more smoketest translations (7 tests total) Translate from Python smoketests: - detect_wasm_bindgen.rs: Tests build rejects wasm_bindgen and getrandom (2 tests) - default_module_clippy.rs: Tests default module passes clippy - delete_database.rs: Tests deleting database stops scheduled reducers - fail_initial_publish.rs: Tests failed publish doesn't corrupt control DB - modules.rs: Tests module update lifecycle and breaking changes (2 tests) Also adds spacetime_build() method to Smoketest for testing build failures. Total: 16 test files translated, 32 tests --- crates/smoketests/src/lib.rs | 16 +++ .../smoketests/tests/default_module_clippy.rs | 24 ++++ crates/smoketests/tests/delete_database.rs | 82 +++++++++++ .../smoketests/tests/detect_wasm_bindgen.rs | 74 ++++++++++ .../smoketests/tests/fail_initial_publish.rs | 93 ++++++++++++ crates/smoketests/tests/modules.rs | 134 ++++++++++++++++++ 6 files changed, 423 insertions(+) create mode 100644 crates/smoketests/tests/default_module_clippy.rs create mode 100644 crates/smoketests/tests/delete_database.rs create mode 100644 crates/smoketests/tests/detect_wasm_bindgen.rs create mode 100644 crates/smoketests/tests/fail_initial_publish.rs create mode 100644 crates/smoketests/tests/modules.rs diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 62087b92220..f081b6a3474 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -284,6 +284,22 @@ impl Smoketest { Ok(()) } + /// Runs `spacetime build` and returns the raw output. + /// + /// Use this when you need to check for build failures (e.g., wasm_bindgen detection). + pub fn spacetime_build(&self) -> Output { + let start = Instant::now(); + let project_path = self.project_dir.path().to_str().unwrap(); + let cli_path = ensure_binaries_built(); + let output = Command::new(&cli_path) + .args(["build", "--project-path", project_path]) + .current_dir(self.project_dir.path()) + .output() + .expect("Failed to execute spacetime build"); + eprintln!("[TIMING] spacetime build: {:?}", start.elapsed()); + output + } + /// Publishes the module and stores the database identity. pub fn publish_module(&mut self) -> Result { self.publish_module_opts(None, false) diff --git a/crates/smoketests/tests/default_module_clippy.rs b/crates/smoketests/tests/default_module_clippy.rs new file mode 100644 index 00000000000..4af80f3aa4d --- /dev/null +++ b/crates/smoketests/tests/default_module_clippy.rs @@ -0,0 +1,24 @@ +//! Tests translated from smoketests/tests/default_module_clippy.py + +use spacetimedb_smoketests::Smoketest; +use std::process::Command; + +/// Ensure that the default rust module has no clippy errors or warnings +#[test] +fn test_default_module_clippy_check() { + // Build a smoketest with the default module code (no custom code) + let test = Smoketest::builder().autopublish(false).build(); + + let output = Command::new("cargo") + .args(["clippy", "--", "-Dwarnings"]) + .current_dir(test.project_dir.path()) + .output() + .expect("Failed to run cargo clippy"); + + assert!( + output.status.success(), + "Default module should have no clippy warnings:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/smoketests/tests/delete_database.rs b/crates/smoketests/tests/delete_database.rs new file mode 100644 index 00000000000..2e2b9cf2a90 --- /dev/null +++ b/crates/smoketests/tests/delete_database.rs @@ -0,0 +1,82 @@ +//! Tests translated from smoketests/tests/delete_database.py + +use spacetimedb_smoketests::Smoketest; +use std::thread; +use std::time::Duration; + +const MODULE_CODE: &str = r#" +use spacetimedb::{ReducerContext, Table, duration}; + +#[spacetimedb::table(name = counter, public)] +pub struct Counter { + #[primary_key] + id: u64, + val: u64 +} + +#[spacetimedb::table(name = scheduled_counter, public, scheduled(inc, at = sched_at))] +pub struct ScheduledCounter { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + sched_at: spacetimedb::ScheduleAt, +} + +#[spacetimedb::reducer] +pub fn inc(ctx: &ReducerContext, arg: ScheduledCounter) { + if let Some(mut counter) = ctx.db.counter().id().find(arg.scheduled_id) { + counter.val += 1; + ctx.db.counter().id().update(counter); + } else { + ctx.db.counter().insert(Counter { + id: arg.scheduled_id, + val: 1, + }); + } +} + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + ctx.db.scheduled_counter().insert(ScheduledCounter { + scheduled_id: 0, + sched_at: duration!(100ms).into(), + }); +} +"#; + +/// Test that deleting a database stops the module. +/// The module is considered stopped if its scheduled reducer stops +/// producing update events. +#[test] +fn test_delete_database() { + let mut test = Smoketest::builder() + .module_code(MODULE_CODE) + .autopublish(false) + .build(); + + let name = format!("test-db-{}", std::process::id()); + test.publish_module_named(&name, false).unwrap(); + + // Start subscription in background to collect updates + // We request many updates but will stop early when we delete the db + let sub = test + .subscribe_background(&["SELECT * FROM counter"], 1000) + .unwrap(); + + // Let the scheduled reducer run for a bit + thread::sleep(Duration::from_secs(2)); + + // Delete the database + test.spacetime(&["delete", &name]).unwrap(); + + // Collect whatever updates we got + let updates = sub.collect().unwrap(); + + // At a rate of 100ms, we shouldn't have more than 20 updates in 2secs. + // But let's say 50, in case the delete gets delayed for some reason. + assert!( + updates.len() <= 50, + "Expected at most 50 updates, got {}. Database may not have stopped.", + updates.len() + ); +} diff --git a/crates/smoketests/tests/detect_wasm_bindgen.rs b/crates/smoketests/tests/detect_wasm_bindgen.rs new file mode 100644 index 00000000000..ad3d322fc83 --- /dev/null +++ b/crates/smoketests/tests/detect_wasm_bindgen.rs @@ -0,0 +1,74 @@ +//! Tests translated from smoketests/tests/detect_wasm_bindgen.py + +use spacetimedb_smoketests::Smoketest; + +/// Module code that uses wasm_bindgen (should be rejected) +const MODULE_CODE_WASM_BINDGEN: &str = r#" +use spacetimedb::{log, ReducerContext}; + +#[spacetimedb::reducer] +pub fn test(_ctx: &ReducerContext) { + log::info!("Hello! {}", now()); +} + +#[wasm_bindgen::prelude::wasm_bindgen] +extern "C" { + fn now() -> i32; +} +"#; + +/// Module code that uses getrandom via rand (should be rejected) +const MODULE_CODE_GETRANDOM: &str = r#" +use spacetimedb::{log, ReducerContext}; + +#[spacetimedb::reducer] +pub fn test(_ctx: &ReducerContext) { + log::info!("Hello! {}", rand::random::()); +} +"#; + +/// Ensure that spacetime build properly catches wasm_bindgen imports +#[test] +fn test_detect_wasm_bindgen() { + let test = Smoketest::builder() + .module_code(MODULE_CODE_WASM_BINDGEN) + .extra_deps(r#"wasm-bindgen = "0.2""#) + .autopublish(false) + .build(); + + let output = test.spacetime_build(); + assert!( + !output.status.success(), + "Expected build to fail with wasm_bindgen" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("wasm-bindgen detected"), + "Expected 'wasm-bindgen detected' in stderr, got: {}", + stderr + ); +} + +/// Ensure that spacetime build properly catches getrandom usage +#[test] +fn test_detect_getrandom() { + let test = Smoketest::builder() + .module_code(MODULE_CODE_GETRANDOM) + .extra_deps(r#"rand = "0.8""#) + .autopublish(false) + .build(); + + let output = test.spacetime_build(); + assert!( + !output.status.success(), + "Expected build to fail with getrandom" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("getrandom usage detected"), + "Expected 'getrandom usage detected' in stderr, got: {}", + stderr + ); +} diff --git a/crates/smoketests/tests/fail_initial_publish.rs b/crates/smoketests/tests/fail_initial_publish.rs new file mode 100644 index 00000000000..5ce4f8f2fcc --- /dev/null +++ b/crates/smoketests/tests/fail_initial_publish.rs @@ -0,0 +1,93 @@ +//! Tests translated from smoketests/tests/fail_initial_publish.py + +use spacetimedb_smoketests::Smoketest; + +/// Module code with a bug: `Person` is the wrong table name, should be `person` +const MODULE_CODE_BROKEN: &str = r#" +use spacetimedb::{client_visibility_filter, Filter}; + +#[spacetimedb::table(name = person, public)] +pub struct Person { + name: String, +} + +#[client_visibility_filter] +// Bug: `Person` is the wrong table name, should be `person`. +const HIDE_PEOPLE_EXCEPT_ME: Filter = Filter::Sql("SELECT * FROM Person WHERE name = 'me'"); +"#; + +/// Fixed module code with correct table name +const MODULE_CODE_FIXED: &str = r#" +use spacetimedb::{client_visibility_filter, Filter}; + +#[spacetimedb::table(name = person, public)] +pub struct Person { + name: String, +} + +#[client_visibility_filter] +const HIDE_PEOPLE_EXCEPT_ME: Filter = Filter::Sql("SELECT * FROM person WHERE name = 'me'"); +"#; + +const FIXED_QUERY: &str = r#""sql": "SELECT * FROM person WHERE name = 'me'""#; + +/// This tests that publishing an invalid module does not leave a broken entry in the control DB. +#[test] +fn test_fail_initial_publish() { + let mut test = Smoketest::builder() + .module_code(MODULE_CODE_BROKEN) + .autopublish(false) + .build(); + + let name = format!("test-db-{}", std::process::id()); + + // First publish should fail due to broken module + let result = test.publish_module_named(&name, false); + assert!( + result.is_err(), + "Expected publish to fail with broken module" + ); + + // Describe should fail because database doesn't exist + let describe_output = test.spacetime_cmd(&["describe", "--json", &name]); + assert!( + !describe_output.status.success(), + "Expected describe to fail for non-existent database" + ); + let stderr = String::from_utf8_lossy(&describe_output.stderr); + assert!( + stderr.contains("No such database"), + "Expected 'No such database' in stderr, got: {}", + stderr + ); + + // We can publish a fixed module under the same database name. + // This used to be broken; the failed initial publish would leave + // the control database in a bad state. + test.write_module_code(MODULE_CODE_FIXED).unwrap(); + test.publish_module_named(&name, false).unwrap(); + + let describe_output = test.spacetime(&["describe", "--json", &name]).unwrap(); + assert!( + describe_output.contains(FIXED_QUERY), + "Expected describe output to contain fixed query.\nGot: {}", + describe_output + ); + + // Publishing the broken code again fails, but the database still exists afterwards, + // with the previous version of the module code. + test.write_module_code(MODULE_CODE_BROKEN).unwrap(); + let result = test.publish_module_named(&name, false); + assert!( + result.is_err(), + "Expected publish to fail with broken module" + ); + + // Database should still exist with the fixed code + let describe_output = test.spacetime(&["describe", "--json", &name]).unwrap(); + assert!( + describe_output.contains(FIXED_QUERY), + "Expected describe output to still contain fixed query after failed update.\nGot: {}", + describe_output + ); +} diff --git a/crates/smoketests/tests/modules.rs b/crates/smoketests/tests/modules.rs new file mode 100644 index 00000000000..5fdc1844665 --- /dev/null +++ b/crates/smoketests/tests/modules.rs @@ -0,0 +1,134 @@ +//! Tests translated from smoketests/tests/modules.py + +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE: &str = r#" +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person)] +pub struct Person { + #[primary_key] + #[auto_inc] + id: u64, + name: String, +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { id: 0, name }); +} + +#[spacetimedb::reducer] +pub fn say_hello(ctx: &ReducerContext) { + for person in ctx.db.person().iter() { + log::info!("Hello, {}!", person.name); + } + log::info!("Hello, World!"); +} +"#; + +/// Breaking change: adds a new column to Person +const MODULE_CODE_BREAKING: &str = r#" +#[spacetimedb::table(name = person)] +pub struct Person { + #[primary_key] + #[auto_inc] + id: u64, + name: String, + age: u8, +} +"#; + +/// Non-breaking change: adds a new table +const MODULE_CODE_ADD_TABLE: &str = r#" +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person)] +pub struct Person { + #[primary_key] + #[auto_inc] + id: u64, + name: String, +} + +#[spacetimedb::table(name = pets)] +pub struct Pet { + species: String, +} + +#[spacetimedb::reducer] +pub fn are_we_updated_yet(ctx: &ReducerContext) { + log::info!("MODULE UPDATED"); +} +"#; + +/// Test publishing a module without the --delete-data option +#[test] +fn test_module_update() { + let mut test = Smoketest::builder() + .module_code(MODULE_CODE) + .autopublish(false) + .build(); + + let name = format!("test-db-{}", std::process::id()); + + // Initial publish + test.publish_module_named(&name, false).unwrap(); + + test.call("add", &["Robert"]).unwrap(); + test.call("add", &["Julie"]).unwrap(); + test.call("add", &["Samantha"]).unwrap(); + test.call("say_hello", &[]).unwrap(); + + let logs = test.logs(100).unwrap(); + assert!(logs.iter().any(|l| l.contains("Hello, Samantha!"))); + assert!(logs.iter().any(|l| l.contains("Hello, Julie!"))); + assert!(logs.iter().any(|l| l.contains("Hello, Robert!"))); + assert!(logs.iter().any(|l| l.contains("Hello, World!"))); + + // Unchanged module is ok + test.publish_module_named(&name, false).unwrap(); + + // Changing an existing table isn't + test.write_module_code(MODULE_CODE_BREAKING).unwrap(); + let result = test.publish_module_named(&name, false); + assert!(result.is_err(), "Expected publish to fail with breaking change"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("manual migration") || err.contains("breaking"), + "Expected migration error, got: {}", + err + ); + + // Check that the old module is still running by calling say_hello + test.call("say_hello", &[]).unwrap(); + + // Adding a table is ok + test.write_module_code(MODULE_CODE_ADD_TABLE).unwrap(); + test.publish_module_named(&name, false).unwrap(); + test.call("are_we_updated_yet", &[]).unwrap(); + + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|l| l.contains("MODULE UPDATED")), + "Expected 'MODULE UPDATED' in logs, got: {:?}", + logs + ); +} + +/// Test uploading a basic module and calling some functions and checking logs +#[test] +fn test_upload_module() { + let test = Smoketest::builder().module_code(MODULE_CODE).build(); + + test.call("add", &["Robert"]).unwrap(); + test.call("add", &["Julie"]).unwrap(); + test.call("add", &["Samantha"]).unwrap(); + test.call("say_hello", &[]).unwrap(); + + let logs = test.logs(100).unwrap(); + assert!(logs.iter().any(|l| l.contains("Hello, Samantha!"))); + assert!(logs.iter().any(|l| l.contains("Hello, Julie!"))); + assert!(logs.iter().any(|l| l.contains("Hello, Robert!"))); + assert!(logs.iter().any(|l| l.contains("Hello, World!"))); +} From 2d996f31270169a0c58fd167c0e77198a2881b7a Mon Sep 17 00:00:00 2001 From: = Date: Fri, 23 Jan 2026 01:38:10 -0500 Subject: [PATCH 007/118] Fix unnecessary rebuilds in ensure_binaries_built Clear CARGO* environment variables (except CARGO_HOME) when spawning child cargo build processes. When running under `cargo test`, cargo sets env vars like CARGO_ENCODED_RUSTFLAGS that differ from a normal build, causing child cargo processes to think they need to recompile. This reduces single-test runtime from ~45s to ~18s by avoiding redundant rebuilds of spacetimedb-standalone and spacetimedb-cli. --- crates/guard/src/lib.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 405821bf05b..9092d0f6e32 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -47,9 +47,19 @@ pub fn ensure_binaries_built() -> PathBuf { args.push("--release"); } - let status = Command::new("cargo") - .args(&args) - .current_dir(workspace_root) + // Clear cargo-provided env vars to avoid unnecessary rebuilds. + // When running under `cargo test`, cargo sets env vars like + // CARGO_ENCODED_RUSTFLAGS that differ from a normal build, + // causing the child cargo to think it needs to recompile. + let mut cmd = Command::new("cargo"); + cmd.args(&args).current_dir(&workspace_root); + for (key, _) in env::vars() { + if key.starts_with("CARGO") && key != "CARGO_HOME" { + cmd.env_remove(&key); + } + } + + let status = cmd .status() .unwrap_or_else(|e| panic!("Failed to build {}: {}", pkg, e)); From 1db4180fa3d4f801d619a0b0acbd786015f8c082 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 23 Jan 2026 02:00:34 -0500 Subject: [PATCH 008/118] Translate 5 more Python smoketests to Rust Add test translations for: - connect_disconnect_from_cli.rs - client connection callbacks - domains.rs - database rename functionality - client_connection_errors.rs - client_connected error handling - confirmed_reads.rs - --confirmed flag for subscriptions/SQL - create_project.rs - spacetime init command Also fix subscription race condition by waiting for initial update before returning from subscribe_background_*, matching Python behavior. --- crates/guard/src/lib.rs | 12 +- crates/smoketests/src/lib.rs | 171 ++++++++++++------ crates/smoketests/tests/add_remove_index.rs | 15 +- crates/smoketests/tests/auto_inc.rs | 5 +- crates/smoketests/tests/call.rs | 4 +- .../tests/client_connection_errors.rs | 98 ++++++++++ crates/smoketests/tests/confirmed_reads.rs | 75 ++++++++ .../tests/connect_disconnect_from_cli.rs | 47 +++++ crates/smoketests/tests/create_project.rs | 74 ++++++++ crates/smoketests/tests/delete_database.rs | 9 +- .../smoketests/tests/detect_wasm_bindgen.rs | 10 +- crates/smoketests/tests/domains.rs | 69 +++++++ .../smoketests/tests/fail_initial_publish.rs | 10 +- crates/smoketests/tests/filtering.rs | 44 +++-- crates/smoketests/tests/modules.rs | 5 +- crates/smoketests/tests/panic.rs | 8 +- crates/smoketests/tests/schedule_reducer.rs | 11 +- crates/smoketests/tests/sql.rs | 4 +- 18 files changed, 530 insertions(+), 141 deletions(-) create mode 100644 crates/smoketests/tests/client_connection_errors.rs create mode 100644 crates/smoketests/tests/confirmed_reads.rs create mode 100644 crates/smoketests/tests/connect_disconnect_from_cli.rs create mode 100644 crates/smoketests/tests/create_project.rs create mode 100644 crates/smoketests/tests/domains.rs diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 9092d0f6e32..4a211868bc3 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -34,11 +34,7 @@ pub fn ensure_binaries_built() -> PathBuf { .unwrap_or_else(|_| workspace_root.join("target")); // Determine profile - let profile = if cfg!(debug_assertions) { - "debug" - } else { - "release" - }; + let profile = if cfg!(debug_assertions) { "debug" } else { "release" }; // Build both binaries (standalone needed by CLI's start command) for pkg in ["spacetimedb-standalone", "spacetimedb-cli"] { @@ -74,11 +70,7 @@ pub fn ensure_binaries_built() -> PathBuf { }; let cli_path = target_dir.join(profile).join(cli_name); - assert!( - cli_path.exists(), - "CLI binary not found at {}", - cli_path.display() - ); + assert!(cli_path.exists(), "CLI binary not found at {}", cli_path.display()); cli_path }) diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index f081b6a3474..3e05a974429 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -162,8 +162,7 @@ log = "0.4" "#, bindings_path_str, features_str, self.extra_deps ); - fs::write(project_dir.path().join("Cargo.toml"), cargo_toml) - .expect("Failed to write Cargo.toml"); + fs::write(project_dir.path().join("Cargo.toml"), cargo_toml).expect("Failed to write Cargo.toml"); // Copy rust-toolchain.toml let toolchain_src = workspace_root.join("rust-toolchain.toml"); @@ -208,7 +207,6 @@ impl Smoketest { SmoketestBuilder::new() } - /// Runs a spacetime CLI command with the configured server. /// /// Returns the command output. The command is run but not yet asserted. @@ -220,13 +218,11 @@ impl Smoketest { // Insert --server after the subcommand if let Some((subcommand, rest)) = args.split_first() { - cmd.arg(subcommand) - .arg("--server") - .arg(&self.server_url) - .args(rest); + cmd.arg(subcommand).arg("--server").arg(&self.server_url).args(rest); } - let output = cmd.current_dir(self.project_dir.path()) + let output = cmd + .current_dir(self.project_dir.path()) .output() .expect("Failed to execute spacetime command"); @@ -279,8 +275,7 @@ impl Smoketest { /// Writes new module code to the project. pub fn write_module_code(&self, code: &str) -> Result<()> { - fs::write(self.project_dir.path().join("src/lib.rs"), code) - .context("Failed to write module code")?; + fs::write(self.project_dir.path().join("src/lib.rs"), code).context("Failed to write module code")?; Ok(()) } @@ -358,7 +353,10 @@ impl Smoketest { } let output = self.spacetime(&args)?; - eprintln!("[TIMING] spacetime publish (after build): {:?}", publish_start.elapsed()); + eprintln!( + "[TIMING] spacetime publish (after build): {:?}", + publish_start.elapsed() + ); eprintln!("[TIMING] publish_module total: {:?}", start.elapsed()); // Parse the identity from output like "identity: abc123..." @@ -376,10 +374,7 @@ impl Smoketest { /// /// Arguments are passed directly to the CLI as strings. pub fn call(&self, name: &str, args: &[&str]) -> Result { - let identity = self - .database_identity - .as_ref() - .context("No database published")?; + let identity = self.database_identity.as_ref().context("No database published")?; let mut cmd_args = vec!["call", "--", identity.as_str(), name]; cmd_args.extend(args); @@ -389,10 +384,7 @@ impl Smoketest { /// Calls a reducer/procedure and returns the full output including stderr. pub fn call_output(&self, name: &str, args: &[&str]) -> Output { - let identity = self - .database_identity - .as_ref() - .expect("No database published"); + let identity = self.database_identity.as_ref().expect("No database published"); let mut cmd_args = vec!["call", "--", identity.as_str(), name]; cmd_args.extend(args); @@ -402,14 +394,18 @@ impl Smoketest { /// Executes a SQL query against the database. pub fn sql(&self, query: &str) -> Result { - let identity = self - .database_identity - .as_ref() - .context("No database published")?; + let identity = self.database_identity.as_ref().context("No database published")?; self.spacetime(&["sql", identity.as_str(), query]) } + /// Executes a SQL query with the --confirmed flag. + pub fn sql_confirmed(&self, query: &str) -> Result { + let identity = self.database_identity.as_ref().context("No database published")?; + + self.spacetime(&["sql", "--confirmed", identity.as_str(), query]) + } + /// Asserts that a SQL query produces the expected output. /// /// Both the actual output and expected string have trailing whitespace @@ -437,10 +433,7 @@ impl Smoketest { /// Fetches the last N log records as JSON values. pub fn log_records(&self, n: usize) -> Result> { - let identity = self - .database_identity - .as_ref() - .context("No database published")?; + let identity = self.database_identity.as_ref().context("No database published")?; let output = self.spacetime(&["logs", "--format=json", "-n", &n.to_string(), "--", identity])?; @@ -456,15 +449,30 @@ impl Smoketest { /// Returns the updates as JSON values. /// For tests that need to perform actions while subscribed, use `subscribe_background` instead. pub fn subscribe(&self, queries: &[&str], n: usize) -> Result> { + self.subscribe_opts(queries, n, false) + } + + /// Starts a subscription with --confirmed flag and waits for N updates. + pub fn subscribe_confirmed(&self, queries: &[&str], n: usize) -> Result> { + self.subscribe_opts(queries, n, true) + } + + /// Internal helper for subscribe with options. + fn subscribe_opts(&self, queries: &[&str], n: usize, confirmed: bool) -> Result> { let start = Instant::now(); - let identity = self - .database_identity - .as_ref() - .context("No database published")?; + let identity = self.database_identity.as_ref().context("No database published")?; let cli_path = ensure_binaries_built(); let mut cmd = Command::new(&cli_path); - cmd.args(["subscribe", "--server", &self.server_url, identity, "-t", "600", "-n", &n.to_string(), "--print-initial-update", "--"]) + let mut args = vec!["subscribe", "--server", &self.server_url, identity, "-t", "30", "-n"]; + let n_str = n.to_string(); + args.push(&n_str); + args.push("--print-initial-update"); + if confirmed { + args.push("--confirmed"); + } + args.push("--"); + cmd.args(&args) .args(queries) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -473,10 +481,7 @@ impl Smoketest { eprintln!("[TIMING] subscribe (n={}): {:?}", n, start.elapsed()); if !output.status.success() { - bail!( - "subscribe failed:\nstderr: {}", - String::from_utf8_lossy(&output.stderr) - ); + bail!("subscribe failed:\nstderr: {}", String::from_utf8_lossy(&output.stderr)); } let stdout = String::from_utf8_lossy(&output.stdout); @@ -492,6 +497,18 @@ impl Smoketest { /// This matches Python's subscribe semantics - start subscription first, /// perform actions, then call the handle to collect results. pub fn subscribe_background(&self, queries: &[&str], n: usize) -> Result { + self.subscribe_background_opts(queries, n, false) + } + + /// Starts a subscription in the background with --confirmed flag. + pub fn subscribe_background_confirmed(&self, queries: &[&str], n: usize) -> Result { + self.subscribe_background_opts(queries, n, true) + } + + /// Internal helper for background subscribe with options. + fn subscribe_background_opts(&self, queries: &[&str], n: usize, confirmed: bool) -> Result { + use std::io::{BufRead, BufReader}; + let identity = self .database_identity .as_ref() @@ -500,16 +517,43 @@ impl Smoketest { let cli_path = ensure_binaries_built(); let mut cmd = Command::new(&cli_path); - // Note: Don't use --print-initial-update here since we want to count only - // the actual updates triggered by subsequent operations - cmd.args(["subscribe", "--server", &self.server_url, &identity, "-t", "30", "-n", &n.to_string(), "--"]) + // Use --print-initial-update so we know when subscription is established + let mut args = vec![ + "subscribe".to_string(), + "--server".to_string(), + self.server_url.clone(), + identity, + "-t".to_string(), + "30".to_string(), + "-n".to_string(), + n.to_string(), + "--print-initial-update".to_string(), + ]; + if confirmed { + args.push("--confirmed".to_string()); + } + args.push("--".to_string()); + cmd.args(&args) .args(queries) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - let child = cmd.spawn().context("Failed to spawn subscribe command")?; + let mut child = cmd.spawn().context("Failed to spawn subscribe command")?; + let stdout = child.stdout.take().context("No stdout from subscribe")?; + let stderr = child.stderr.take().context("No stderr from subscribe")?; + let mut reader = BufReader::new(stdout); + + // Wait for initial update line - this blocks until subscription is established + let mut init_line = String::new(); + reader + .read_line(&mut init_line) + .context("Failed to read initial update from subscribe")?; + eprintln!("[SUBSCRIBE] initial update received: {}", init_line.trim()); + Ok(SubscriptionHandle { child, + reader, + stderr, n, start: Instant::now(), }) @@ -519,38 +563,49 @@ impl Smoketest { /// Handle for a background subscription. pub struct SubscriptionHandle { child: std::process::Child, + reader: std::io::BufReader, + stderr: std::process::ChildStderr, n: usize, start: Instant, } impl SubscriptionHandle { /// Wait for the subscription to complete and return the updates. - pub fn collect(self) -> Result> { - let output = self.child.wait_with_output().context("Failed to wait for subscribe command")?; - eprintln!("[TIMING] subscribe_background (n={}): {:?}", self.n, self.start.elapsed()); + pub fn collect(mut self) -> Result> { + use std::io::{BufRead, Read}; + + // Read remaining lines from stdout + let mut updates = Vec::new(); + for line in self.reader.by_ref().lines() { + let line = line.context("Failed to read line from subscribe")?; + if !line.trim().is_empty() { + let value: serde_json::Value = + serde_json::from_str(&line).context("Failed to parse subscription update")?; + updates.push(value); + } + } - if !output.status.success() { - bail!( - "subscribe failed:\nstderr: {}", - String::from_utf8_lossy(&output.stderr) - ); + // Wait for child to complete + let status = self.child.wait().context("Failed to wait for subscribe")?; + eprintln!( + "[TIMING] subscribe_background (n={}): {:?}", + self.n, + self.start.elapsed() + ); + + if !status.success() { + let mut stderr_buf = String::new(); + self.stderr.read_to_string(&mut stderr_buf).ok(); + bail!("subscribe failed:\nstderr: {}", stderr_buf); } - let stdout = String::from_utf8_lossy(&output.stdout); - stdout - .lines() - .filter(|line| !line.trim().is_empty()) - .map(|line| serde_json::from_str(line).context("Failed to parse subscription update")) - .collect() + Ok(updates) } } /// Normalizes whitespace by trimming trailing whitespace from each line. fn normalize_whitespace(s: &str) -> String { - s.lines() - .map(|line| line.trim_end()) - .collect::>() - .join("\n") + s.lines().map(|line| line.trim_end()).collect::>().join("\n") } #[cfg(test)] diff --git a/crates/smoketests/tests/add_remove_index.rs b/crates/smoketests/tests/add_remove_index.rs index bc1512c92a4..0d166d3774f 100644 --- a/crates/smoketests/tests/add_remove_index.rs +++ b/crates/smoketests/tests/add_remove_index.rs @@ -54,10 +54,7 @@ const JOIN_QUERY: &str = "select t1.* from t1 join t2 on t1.id = t2.id where t2. /// and the unindexed versions should reject subscriptions. #[test] fn test_add_then_remove_index() { - let mut test = Smoketest::builder() - .module_code(MODULE_CODE) - .autopublish(false) - .build(); + let mut test = Smoketest::builder().module_code(MODULE_CODE).autopublish(false).build(); let name = format!("test-db-{}", std::process::id()); @@ -65,10 +62,7 @@ fn test_add_then_remove_index() { // There are no indices, resulting in an unsupported unindexed join. test.publish_module_named(&name, false).unwrap(); let result = test.subscribe(&[JOIN_QUERY], 0); - assert!( - result.is_err(), - "Expected subscription to fail without indices" - ); + assert!(result.is_err(), "Expected subscription to fail without indices"); // Publish the indexed version. // Now we have indices, so the query should be accepted. @@ -91,8 +85,5 @@ fn test_add_then_remove_index() { test.write_module_code(MODULE_CODE).unwrap(); test.publish_module_named(&name, false).unwrap(); let result = test.subscribe(&[JOIN_QUERY], 0); - assert!( - result.is_err(), - "Expected subscription to fail after removing indices" - ); + assert!(result.is_err(), "Expected subscription to fail after removing indices"); } diff --git a/crates/smoketests/tests/auto_inc.rs b/crates/smoketests/tests/auto_inc.rs index cd4633e0ce3..dce8c1ec781 100644 --- a/crates/smoketests/tests/auto_inc.rs +++ b/crates/smoketests/tests/auto_inc.rs @@ -141,7 +141,10 @@ fn do_test_autoinc_unique(int_ty: &str) { // Auto-inc tries to assign id 2, but Robert already has it - should fail let result = test.call(&format!("add_new_{}", int_ty), &[r#""Failure""#]); - assert!(result.is_err(), "Expected add_new to fail due to unique constraint violation"); + assert!( + result.is_err(), + "Expected add_new to fail due to unique constraint violation" + ); test.call(&format!("say_hello_{}", int_ty), &[]).unwrap(); diff --git a/crates/smoketests/tests/call.rs b/crates/smoketests/tests/call.rs index 96b0bc80211..8ba7c717cda 100644 --- a/crates/smoketests/tests/call.rs +++ b/crates/smoketests/tests/call.rs @@ -139,9 +139,7 @@ pub struct Person { /// Check calling into a database with no reducers/procedures raises error #[test] fn test_call_empty_errors() { - let test = Smoketest::builder() - .module_code(CALL_EMPTY_MODULE_CODE) - .build(); + let test = Smoketest::builder().module_code(CALL_EMPTY_MODULE_CODE).build(); let identity = test.database_identity.as_ref().unwrap(); diff --git a/crates/smoketests/tests/client_connection_errors.rs b/crates/smoketests/tests/client_connection_errors.rs new file mode 100644 index 00000000000..906d4569629 --- /dev/null +++ b/crates/smoketests/tests/client_connection_errors.rs @@ -0,0 +1,98 @@ +//! Tests translated from smoketests/tests/client_connected_error_rejects_connection.py + +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE_REJECT: &str = r#" +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = all_u8s, public)] +pub struct AllU8s { + number: u8, +} + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + for i in u8::MIN..=u8::MAX { + ctx.db.all_u8s().insert(AllU8s { number: i }); + } +} + +#[spacetimedb::reducer(client_connected)] +pub fn identity_connected(_ctx: &ReducerContext) -> Result<(), String> { + Err("Rejecting connection from client".to_string()) +} + +#[spacetimedb::reducer(client_disconnected)] +pub fn identity_disconnected(_ctx: &ReducerContext) { + panic!("This should never be called, since we reject all connections!") +} +"#; + +const MODULE_CODE_DISCONNECT_PANIC: &str = r#" +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = all_u8s, public)] +pub struct AllU8s { + number: u8, +} + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + for i in u8::MIN..=u8::MAX { + ctx.db.all_u8s().insert(AllU8s { number: i }); + } +} + +#[spacetimedb::reducer(client_connected)] +pub fn identity_connected(_ctx: &ReducerContext) -> Result<(), String> { + Ok(()) +} + +#[spacetimedb::reducer(client_disconnected)] +pub fn identity_disconnected(_ctx: &ReducerContext) { + panic!("This should be called, but the `st_client` row should still be deleted") +} +"#; + +/// Test that client_connected returning an error rejects the connection +#[test] +fn test_client_connected_error_rejects_connection() { + let test = Smoketest::builder().module_code(MODULE_CODE_REJECT).build(); + + // Subscribe should fail because client_connected returns an error + let result = test.subscribe(&["SELECT * FROM all_u8s"], 0); + assert!( + result.is_err(), + "Expected subscribe to fail when client_connected returns error" + ); + + let logs = test.logs(100).unwrap(); + assert!( + logs.iter().any(|l| l.contains("Rejecting connection from client")), + "Expected rejection message in logs: {:?}", + logs + ); + assert!( + !logs.iter().any(|l| l.contains("This should never be called")), + "client_disconnected should not have been called: {:?}", + logs + ); +} + +/// Test that client_disconnected panicking still cleans up the st_client row +#[test] +fn test_client_disconnected_error_still_deletes_st_client() { + let test = Smoketest::builder().module_code(MODULE_CODE_DISCONNECT_PANIC).build(); + + // Subscribe should succeed (client_connected returns Ok) + let result = test.subscribe(&["SELECT * FROM all_u8s"], 0); + assert!(result.is_ok(), "Expected subscribe to succeed"); + + let logs = test.logs(100).unwrap(); + assert!( + logs.iter() + .any(|l| { l.contains("This should be called, but the `st_client` row should still be deleted") }), + "Expected disconnect panic message in logs: {:?}", + logs + ); +} diff --git a/crates/smoketests/tests/confirmed_reads.rs b/crates/smoketests/tests/confirmed_reads.rs new file mode 100644 index 00000000000..413211571bc --- /dev/null +++ b/crates/smoketests/tests/confirmed_reads.rs @@ -0,0 +1,75 @@ +//! Tests translated from smoketests/tests/confirmed_reads.py +//! +//! TODO: We only test that we can pass a --confirmed flag and that things +//! appear to work as if we hadn't. Without controlling the server, we can't +//! test that there is any difference in behavior. + +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE: &str = r#" +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = person, public)] +pub struct Person { + name: String, +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { name }); +} +"#; + +/// Tests that subscribing with confirmed=true receives updates +#[test] +fn test_confirmed_reads_receive_updates() { + let test = Smoketest::builder().module_code(MODULE_CODE).build(); + + // Start subscription in background with confirmed flag + let sub = test + .subscribe_background_confirmed(&["SELECT * FROM person"], 2) + .unwrap(); + + // Insert via reducer + test.call("add", &["Horst"]).unwrap(); + + // Insert via SQL + test.sql("INSERT INTO person (name) VALUES ('Egon')").unwrap(); + + // Collect updates + let events = sub.collect().unwrap(); + + assert_eq!(events.len(), 2, "Expected 2 updates, got {:?}", events); + + // Check that we got the expected inserts + let horst_insert = serde_json::json!({ + "person": { + "deletes": [], + "inserts": [{"name": "Horst"}] + } + }); + let egon_insert = serde_json::json!({ + "person": { + "deletes": [], + "inserts": [{"name": "Egon"}] + } + }); + + assert_eq!(events[0], horst_insert); + assert_eq!(events[1], egon_insert); +} + +/// Tests that an SQL operation with confirmed=true returns a result +#[test] +fn test_sql_with_confirmed_reads_receives_result() { + let test = Smoketest::builder().module_code(MODULE_CODE).build(); + + // Insert with confirmed + test.sql_confirmed("INSERT INTO person (name) VALUES ('Horst')") + .unwrap(); + + // Query with confirmed + let result = test.sql_confirmed("SELECT * FROM person").unwrap(); + + assert!(result.contains("Horst"), "Expected 'Horst' in result: {}", result); +} diff --git a/crates/smoketests/tests/connect_disconnect_from_cli.rs b/crates/smoketests/tests/connect_disconnect_from_cli.rs new file mode 100644 index 00000000000..c5eeb4d87a0 --- /dev/null +++ b/crates/smoketests/tests/connect_disconnect_from_cli.rs @@ -0,0 +1,47 @@ +//! Tests translated from smoketests/tests/connect_disconnect_from_cli.py + +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE: &str = r#" +use spacetimedb::{log, ReducerContext}; + +#[spacetimedb::reducer(client_connected)] +pub fn connected(_ctx: &ReducerContext) { + log::info!("_connect called"); +} + +#[spacetimedb::reducer(client_disconnected)] +pub fn disconnected(_ctx: &ReducerContext) { + log::info!("disconnect called"); +} + +#[spacetimedb::reducer] +pub fn say_hello(_ctx: &ReducerContext) { + log::info!("Hello, World!"); +} +"#; + +/// Ensure that the connect and disconnect functions are called when invoking a reducer from the CLI +#[test] +fn test_conn_disconn() { + let test = Smoketest::builder().module_code(MODULE_CODE).build(); + + test.call("say_hello", &[]).unwrap(); + + let logs = test.logs(10).unwrap(); + assert!( + logs.iter().any(|l| l.contains("_connect called")), + "Expected '_connect called' in logs: {:?}", + logs + ); + assert!( + logs.iter().any(|l| l.contains("disconnect called")), + "Expected 'disconnect called' in logs: {:?}", + logs + ); + assert!( + logs.iter().any(|l| l.contains("Hello, World!")), + "Expected 'Hello, World!' in logs: {:?}", + logs + ); +} diff --git a/crates/smoketests/tests/create_project.rs b/crates/smoketests/tests/create_project.rs new file mode 100644 index 00000000000..566778888d4 --- /dev/null +++ b/crates/smoketests/tests/create_project.rs @@ -0,0 +1,74 @@ +//! Tests translated from smoketests/tests/create_project.py + +use spacetimedb_guard::ensure_binaries_built; +use std::process::Command; +use tempfile::tempdir; + +/// Ensure that the CLI is able to create a local project. +/// This test does not depend on a running spacetimedb instance. +#[test] +fn test_create_project() { + let cli_path = ensure_binaries_built(); + let tmpdir = tempdir().expect("Failed to create temp dir"); + let tmpdir_path = tmpdir.path().to_str().unwrap(); + + // Without --lang, init should fail + let output = Command::new(&cli_path) + .args(["init", "--non-interactive", "test-project"]) + .current_dir(tmpdir_path) + .output() + .expect("Failed to run spacetime init"); + assert!(!output.status.success(), "Expected init without --lang to fail"); + + // Without --project-path to specify location, init should fail + let output = Command::new(&cli_path) + .args([ + "init", + "--non-interactive", + "--project-path", + tmpdir_path, + "test-project", + ]) + .output() + .expect("Failed to run spacetime init"); + assert!( + !output.status.success(), + "Expected init without --lang to fail even with --project-path" + ); + + // With all required args, init should succeed + let output = Command::new(&cli_path) + .args([ + "init", + "--non-interactive", + "--lang=rust", + "--project-path", + tmpdir_path, + "test-project", + ]) + .output() + .expect("Failed to run spacetime init"); + assert!( + output.status.success(), + "Expected init to succeed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + // Running init again in the same directory should fail (already exists) + let output = Command::new(&cli_path) + .args([ + "init", + "--non-interactive", + "--lang=rust", + "--project-path", + tmpdir_path, + "test-project", + ]) + .output() + .expect("Failed to run spacetime init"); + assert!( + !output.status.success(), + "Expected init to fail when project already exists" + ); +} diff --git a/crates/smoketests/tests/delete_database.rs b/crates/smoketests/tests/delete_database.rs index 2e2b9cf2a90..8426b382074 100644 --- a/crates/smoketests/tests/delete_database.rs +++ b/crates/smoketests/tests/delete_database.rs @@ -49,19 +49,14 @@ pub fn init(ctx: &ReducerContext) { /// producing update events. #[test] fn test_delete_database() { - let mut test = Smoketest::builder() - .module_code(MODULE_CODE) - .autopublish(false) - .build(); + let mut test = Smoketest::builder().module_code(MODULE_CODE).autopublish(false).build(); let name = format!("test-db-{}", std::process::id()); test.publish_module_named(&name, false).unwrap(); // Start subscription in background to collect updates // We request many updates but will stop early when we delete the db - let sub = test - .subscribe_background(&["SELECT * FROM counter"], 1000) - .unwrap(); + let sub = test.subscribe_background(&["SELECT * FROM counter"], 1000).unwrap(); // Let the scheduled reducer run for a bit thread::sleep(Duration::from_secs(2)); diff --git a/crates/smoketests/tests/detect_wasm_bindgen.rs b/crates/smoketests/tests/detect_wasm_bindgen.rs index ad3d322fc83..9b99ccb6280 100644 --- a/crates/smoketests/tests/detect_wasm_bindgen.rs +++ b/crates/smoketests/tests/detect_wasm_bindgen.rs @@ -37,10 +37,7 @@ fn test_detect_wasm_bindgen() { .build(); let output = test.spacetime_build(); - assert!( - !output.status.success(), - "Expected build to fail with wasm_bindgen" - ); + assert!(!output.status.success(), "Expected build to fail with wasm_bindgen"); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -60,10 +57,7 @@ fn test_detect_getrandom() { .build(); let output = test.spacetime_build(); - assert!( - !output.status.success(), - "Expected build to fail with getrandom" - ); + assert!(!output.status.success(), "Expected build to fail with getrandom"); let stderr = String::from_utf8_lossy(&output.stderr); assert!( diff --git a/crates/smoketests/tests/domains.rs b/crates/smoketests/tests/domains.rs new file mode 100644 index 00000000000..35d31202aeb --- /dev/null +++ b/crates/smoketests/tests/domains.rs @@ -0,0 +1,69 @@ +//! Tests translated from smoketests/tests/domains.py + +use spacetimedb_smoketests::Smoketest; + +/// Tests the functionality of the rename command +#[test] +fn test_set_name() { + let mut test = Smoketest::builder().autopublish(false).build(); + + let orig_name = format!("test-db-{}", std::process::id()); + test.publish_module_named(&orig_name, false).unwrap(); + + let rand_name = format!("test-db-{}-renamed", std::process::id()); + + // This should fail before there's a db with this name + let result = test.spacetime(&["logs", &rand_name]); + assert!(result.is_err(), "Expected logs to fail for non-existent name"); + + // Rename the database + let identity = test.database_identity.as_ref().unwrap(); + test.spacetime(&["rename", "--to", &rand_name, identity]).unwrap(); + + // Now logs should work with the new name + test.spacetime(&["logs", &rand_name]).unwrap(); + + // Original name should no longer work + let result = test.spacetime(&["logs", &orig_name]); + assert!(result.is_err(), "Expected logs to fail for original name after rename"); +} + +/// Test how we treat the / character in published names +#[test] +fn test_subdomain_behavior() { + let mut test = Smoketest::builder().autopublish(false).build(); + + let root_name = format!("test-db-{}", std::process::id()); + test.publish_module_named(&root_name, false).unwrap(); + + // Double slash should fail + let double_slash_name = format!("{}//test", root_name); + let result = test.publish_module_named(&double_slash_name, false); + assert!(result.is_err(), "Expected publish to fail with double slash in name"); + + // Trailing slash should fail + let trailing_slash_name = format!("{}/test/", root_name); + let result = test.publish_module_named(&trailing_slash_name, false); + assert!(result.is_err(), "Expected publish to fail with trailing slash in name"); +} + +/// Test that we can't rename to a name already in use +#[test] +fn test_set_to_existing_name() { + let mut test = Smoketest::builder().autopublish(false).build(); + + // Publish first database (no name) + test.publish_module().unwrap(); + let id_to_rename = test.database_identity.clone().unwrap(); + + // Publish second database with a name + let rename_to = format!("test-db-{}-target", std::process::id()); + test.publish_module_named(&rename_to, false).unwrap(); + + // Try to rename first db to the name of the second - should fail + let result = test.spacetime(&["rename", "--to", &rename_to, &id_to_rename]); + assert!( + result.is_err(), + "Expected rename to fail when target name is already in use" + ); +} diff --git a/crates/smoketests/tests/fail_initial_publish.rs b/crates/smoketests/tests/fail_initial_publish.rs index 5ce4f8f2fcc..a30a05e7b28 100644 --- a/crates/smoketests/tests/fail_initial_publish.rs +++ b/crates/smoketests/tests/fail_initial_publish.rs @@ -43,10 +43,7 @@ fn test_fail_initial_publish() { // First publish should fail due to broken module let result = test.publish_module_named(&name, false); - assert!( - result.is_err(), - "Expected publish to fail with broken module" - ); + assert!(result.is_err(), "Expected publish to fail with broken module"); // Describe should fail because database doesn't exist let describe_output = test.spacetime_cmd(&["describe", "--json", &name]); @@ -78,10 +75,7 @@ fn test_fail_initial_publish() { // with the previous version of the module code. test.write_module_code(MODULE_CODE_BROKEN).unwrap(); let result = test.publish_module_named(&name, false); - assert!( - result.is_err(), - "Expected publish to fail with broken module" - ); + assert!(result.is_err(), "Expected publish to fail with broken module"); // Database should still exist with the fixed code let describe_output = test.spacetime(&["describe", "--json", &name]).unwrap(); diff --git a/crates/smoketests/tests/filtering.rs b/crates/smoketests/tests/filtering.rs index 8f891d3f517..6f39a504b0e 100644 --- a/crates/smoketests/tests/filtering.rs +++ b/crates/smoketests/tests/filtering.rs @@ -284,8 +284,10 @@ fn test_filtering() { ); // Add some nonunique people. - test.call("insert_nonunique_person", &["23", r#""Alice""#, "true"]).unwrap(); - test.call("insert_nonunique_person", &["42", r#""Bob""#, "true"]).unwrap(); + test.call("insert_nonunique_person", &["23", r#""Alice""#, "true"]) + .unwrap(); + test.call("insert_nonunique_person", &["42", r#""Bob""#, "true"]) + .unwrap(); // Find a nonunique person who is there. test.call("find_nonunique_person", &["23"]).unwrap(); @@ -320,7 +322,8 @@ fn test_filtering() { ); // Insert a non-human, then find humans, then find non-humans - test.call("insert_nonunique_person", &["64", r#""Jibbitty""#, "false"]).unwrap(); + test.call("insert_nonunique_person", &["64", r#""Jibbitty""#, "false"]) + .unwrap(); test.call("find_nonunique_humans", &[]).unwrap(); let logs = test.logs(4).unwrap(); assert!( @@ -342,7 +345,8 @@ fn test_filtering() { ); // Add another person with the same id, and find them both. - test.call("insert_nonunique_person", &["23", r#""Claire""#, "true"]).unwrap(); + test.call("insert_nonunique_person", &["23", r#""Claire""#, "true"]) + .unwrap(); test.call("find_nonunique_person", &["23"]).unwrap(); let logs = test.logs(4).unwrap(); assert!( @@ -390,10 +394,14 @@ fn test_filtering() { ); // As above, but for non-unique indices: check for consistency between index and DB - test.call("insert_indexed_person", &["7", r#""James""#, r#""Bond""#]).unwrap(); - test.call("insert_indexed_person", &["79", r#""Gold""#, r#""Bond""#]).unwrap(); - test.call("insert_indexed_person", &["1", r#""Hydrogen""#, r#""Bond""#]).unwrap(); - test.call("insert_indexed_person", &["100", r#""Whiskey""#, r#""Bond""#]).unwrap(); + test.call("insert_indexed_person", &["7", r#""James""#, r#""Bond""#]) + .unwrap(); + test.call("insert_indexed_person", &["79", r#""Gold""#, r#""Bond""#]) + .unwrap(); + test.call("insert_indexed_person", &["1", r#""Hydrogen""#, r#""Bond""#]) + .unwrap(); + test.call("insert_indexed_person", &["100", r#""Whiskey""#, r#""Bond""#]) + .unwrap(); test.call("delete_indexed_person", &["100"]).unwrap(); test.call("find_indexed_people", &[r#""Bond""#]).unwrap(); let logs = test.logs(10).unwrap(); @@ -408,12 +416,15 @@ fn test_filtering() { logs ); assert!( - logs.iter().any(|msg| msg.contains("INDEXED FOUND: id 1: Bond, Hydrogen")), + logs.iter() + .any(|msg| msg.contains("INDEXED FOUND: id 1: Bond, Hydrogen")), "Expected 'INDEXED FOUND: id 1: Bond, Hydrogen' in logs, got: {:?}", logs ); assert!( - !logs.iter().any(|msg| msg.contains("INDEXED FOUND: id 100: Bond, Whiskey")), + !logs + .iter() + .any(|msg| msg.contains("INDEXED FOUND: id 100: Bond, Whiskey")), "Expected no 'INDEXED FOUND: id 100: Bond, Whiskey' in logs, got: {:?}", logs ); @@ -430,12 +441,15 @@ fn test_filtering() { logs ); assert!( - logs.iter().any(|msg| msg.contains("INDEXED FOUND: id 1: Bond, Hydrogen")), + logs.iter() + .any(|msg| msg.contains("INDEXED FOUND: id 1: Bond, Hydrogen")), "Expected 'INDEXED FOUND: id 1: Bond, Hydrogen' in logs, got: {:?}", logs ); assert!( - !logs.iter().any(|msg| msg.contains("INDEXED FOUND: id 100: Bond, Whiskey")), + !logs + .iter() + .any(|msg| msg.contains("INDEXED FOUND: id 100: Bond, Whiskey")), "Expected no 'INDEXED FOUND: id 100: Bond, Whiskey' in logs, got: {:?}", logs ); @@ -453,10 +467,12 @@ fn test_filtering() { // Inserting into a table with unique constraints fails // when the second row has the same value in the constrained columns as the first row. // In this case, the table has `#[unique] id` and `#[unique] nick` but not `#[unique] name`. - test.call("insert_person_twice", &["23", r#""Alice""#, r#""al""#]).unwrap(); + test.call("insert_person_twice", &["23", r#""Alice""#, r#""al""#]) + .unwrap(); let logs = test.logs(2).unwrap(); assert!( - logs.iter().any(|msg| msg.contains("UNIQUE CONSTRAINT VIOLATION ERROR: id = 23, nick = al")), + logs.iter() + .any(|msg| msg.contains("UNIQUE CONSTRAINT VIOLATION ERROR: id = 23, nick = al")), "Expected 'UNIQUE CONSTRAINT VIOLATION ERROR: id = 23, nick = al' in logs, got: {:?}", logs ); diff --git a/crates/smoketests/tests/modules.rs b/crates/smoketests/tests/modules.rs index 5fdc1844665..2340bfa1e16 100644 --- a/crates/smoketests/tests/modules.rs +++ b/crates/smoketests/tests/modules.rs @@ -65,10 +65,7 @@ pub fn are_we_updated_yet(ctx: &ReducerContext) { /// Test publishing a module without the --delete-data option #[test] fn test_module_update() { - let mut test = Smoketest::builder() - .module_code(MODULE_CODE) - .autopublish(false) - .build(); + let mut test = Smoketest::builder().module_code(MODULE_CODE).autopublish(false).build(); let name = format!("test-db-{}", std::process::id()); diff --git a/crates/smoketests/tests/panic.rs b/crates/smoketests/tests/panic.rs index 06681d9f3a9..71c75470bbd 100644 --- a/crates/smoketests/tests/panic.rs +++ b/crates/smoketests/tests/panic.rs @@ -26,9 +26,7 @@ fn second(_ctx: &ReducerContext) { /// Tests to check if a SpacetimeDB module can handle a panic without corrupting #[test] fn test_panic() { - let test = Smoketest::builder() - .module_code(PANIC_MODULE_CODE) - .build(); + let test = Smoketest::builder().module_code(PANIC_MODULE_CODE).build(); // First reducer should panic/fail let result = test.call("first", &[]); @@ -57,9 +55,7 @@ fn fail(_ctx: &ReducerContext) -> Result<(), String> { /// Tests to ensure an error message returned from a reducer gets printed to logs #[test] fn test_reducer_error_message() { - let test = Smoketest::builder() - .module_code(REDUCER_ERROR_MODULE_CODE) - .build(); + let test = Smoketest::builder().module_code(REDUCER_ERROR_MODULE_CODE).build(); // Reducer should fail with error let result = test.call("fail", &[]); diff --git a/crates/smoketests/tests/schedule_reducer.rs b/crates/smoketests/tests/schedule_reducer.rs index a17caace609..9c602867f38 100644 --- a/crates/smoketests/tests/schedule_reducer.rs +++ b/crates/smoketests/tests/schedule_reducer.rs @@ -47,9 +47,7 @@ fn reducer(_ctx: &ReducerContext, args: ScheduledReducerArgs) { /// Ensure cancelling a reducer works #[test] fn test_cancel_reducer() { - let test = Smoketest::builder() - .module_code(CANCEL_REDUCER_MODULE_CODE) - .build(); + let test = Smoketest::builder().module_code(CANCEL_REDUCER_MODULE_CODE).build(); // Wait for any scheduled reducers to potentially run thread::sleep(Duration::from_secs(2)); @@ -132,7 +130,8 @@ fn test_scheduled_table_subscription_repeated_reducer() { assert!( invoked_count > 2, "Expected repeated reducer to run more than twice, but it ran {} times. Logs: {:?}", - invoked_count, logs + invoked_count, + logs ); } @@ -158,9 +157,7 @@ fn do_insert(ctx: &ReducerContext, x: String) { /// Check that volatile_nonatomic_schedule_immediate works #[test] fn test_volatile_nonatomic_schedule_immediate() { - let test = Smoketest::builder() - .module_code(VOLATILE_NONATOMIC_MODULE_CODE) - .build(); + let test = Smoketest::builder().module_code(VOLATILE_NONATOMIC_MODULE_CODE).build(); // Insert directly first test.call("do_insert", &[r#""yay!""#]).unwrap(); diff --git a/crates/smoketests/tests/sql.rs b/crates/smoketests/tests/sql.rs index c865aa3d468..a908196aeab 100644 --- a/crates/smoketests/tests/sql.rs +++ b/crates/smoketests/tests/sql.rs @@ -130,9 +130,7 @@ pub fn test(ctx: &ReducerContext) { /// This test is designed to test the format of the output of sql queries #[test] fn test_sql_format() { - let test = Smoketest::builder() - .module_code(SQL_FORMAT_MODULE_CODE) - .build(); + let test = Smoketest::builder().module_code(SQL_FORMAT_MODULE_CODE).build(); test.call("test", &[]).unwrap(); From bea9069726af262154f999efc269842666258cd6 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 23 Jan 2026 02:03:07 -0500 Subject: [PATCH 009/118] Add views and auto_migration smoketest translations Translate tests for: - views.rs: st_view_* system tables, namespace collisions, SQL views - auto_migration.rs: schema changes, add table migration --- crates/smoketests/src/lib.rs | 13 ++ crates/smoketests/tests/auto_migration.rs | 273 ++++++++++++++++++++++ crates/smoketests/tests/views.rs | 239 +++++++++++++++++++ 3 files changed, 525 insertions(+) create mode 100644 crates/smoketests/tests/auto_migration.rs create mode 100644 crates/smoketests/tests/views.rs diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 3e05a974429..715521e8e88 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -308,6 +308,19 @@ impl Smoketest { self.publish_module_opts(Some(name), clear) } + /// Re-publishes the module to the existing database identity with optional clear. + /// + /// This is useful for testing auto-migrations where you want to update + /// the module without clearing the database. + pub fn publish_module_clear(&mut self, clear: bool) -> Result { + let identity = self + .database_identity + .as_ref() + .context("No database published yet")? + .clone(); + self.publish_module_opts(Some(&identity), clear) + } + /// Internal helper for publishing with options. fn publish_module_opts(&mut self, name: Option<&str>, clear: bool) -> Result { let start = Instant::now(); diff --git a/crates/smoketests/tests/auto_migration.rs b/crates/smoketests/tests/auto_migration.rs new file mode 100644 index 00000000000..bf3109be822 --- /dev/null +++ b/crates/smoketests/tests/auto_migration.rs @@ -0,0 +1,273 @@ +//! Tests translated from smoketests/tests/auto_migration.py + +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE_SIMPLE: &str = r#" +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person)] +pub struct Person { + name: String, +} + +#[spacetimedb::reducer] +pub fn add_person(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { name }); +} + +#[spacetimedb::reducer] +pub fn print_persons(ctx: &ReducerContext, prefix: String) { + for person in ctx.db.person().iter() { + log::info!("{}: {}", prefix, person.name); + } +} +"#; + +const MODULE_CODE_UPDATED_INCOMPATIBLE: &str = r#" +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person)] +pub struct Person { + name: String, + age: u128, +} + +#[spacetimedb::reducer] +pub fn add_person(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { name, age: 70 }); +} + +#[spacetimedb::reducer] +pub fn print_persons(ctx: &ReducerContext, prefix: String) { + for person in ctx.db.person().iter() { + log::info!("{}: {}", prefix, person.name); + } +} +"#; + +/// Tests that a module with invalid schema changes cannot be published without -c or a migration. +#[test] +fn test_reject_schema_changes() { + let mut test = Smoketest::builder() + .module_code(MODULE_CODE_SIMPLE) + .build(); + + // Try to update with incompatible schema (adding column without default) + test.write_module_code(MODULE_CODE_UPDATED_INCOMPATIBLE) + .unwrap(); + let result = test.publish_module_clear(false); + + assert!( + result.is_err(), + "Expected publish to fail with incompatible schema change" + ); +} + +const MODULE_CODE_INIT: &str = r#" +use spacetimedb::{log, ReducerContext, Table, SpacetimeType}; +use PersonKind::*; + +#[spacetimedb::table(name = person, public)] +pub struct Person { + name: String, + kind: PersonKind, +} + +#[spacetimedb::reducer] +pub fn add_person(ctx: &ReducerContext, name: String, kind: String) { + let kind = kind_from_string(kind); + ctx.db.person().insert(Person { name, kind }); +} + +#[spacetimedb::reducer] +pub fn print_persons(ctx: &ReducerContext, prefix: String) { + for person in ctx.db.person().iter() { + let kind = kind_to_string(person.kind); + log::info!("{prefix}: {} - {kind}", person.name); + } +} + +#[spacetimedb::table(name = point_mass)] +pub struct PointMass { + mass: f64, + position: Vector2, +} + +#[derive(SpacetimeType, Clone, Copy)] +pub struct Vector2 { + x: f64, + y: f64, +} + +#[spacetimedb::table(name = person_info)] +pub struct PersonInfo { + #[primary_key] + id: u64, +} + +#[derive(SpacetimeType, Clone, Copy, PartialEq, Eq)] +pub enum PersonKind { + Student, +} + +fn kind_from_string(_: String) -> PersonKind { + Student +} + +fn kind_to_string(Student: PersonKind) -> &'static str { + "Student" +} +"#; + +const MODULE_CODE_UPDATED: &str = r#" +use spacetimedb::{log, ReducerContext, Table, SpacetimeType}; +use PersonKind::*; + +#[spacetimedb::table(name = person, public)] +pub struct Person { + name: String, + kind: PersonKind, +} + +#[spacetimedb::reducer] +pub fn add_person(ctx: &ReducerContext, name: String, kind: String) { + let kind = kind_from_string(kind); + ctx.db.person().insert(Person { name, kind }); +} + +#[spacetimedb::reducer] +pub fn print_persons(ctx: &ReducerContext, prefix: String) { + for person in ctx.db.person().iter() { + let kind = kind_to_string(person.kind); + log::info!("{prefix}: {} - {kind}", person.name); + } +} + +#[spacetimedb::table(name = point_mass)] +pub struct PointMass { + mass: f64, + position: Vector2, +} + +#[derive(SpacetimeType, Clone, Copy)] +pub struct Vector2 { + x: f64, + y: f64, +} + +#[spacetimedb::table(name = person_info)] +pub struct PersonInfo { + #[primary_key] + #[auto_inc] + id: u64, +} + +#[derive(SpacetimeType, Clone, Copy, PartialEq, Eq)] +pub enum PersonKind { + Student, + Professor, +} + +fn kind_from_string(kind: String) -> PersonKind { + match &*kind { + "Student" => Student, + "Professor" => Professor, + _ => panic!(), + } +} + +fn kind_to_string(kind: PersonKind) -> &'static str { + match kind { + Student => "Student", + Professor => "Professor", + } +} + +#[spacetimedb::table(name = book, public)] +pub struct Book { + isbn: String, +} + +#[spacetimedb::reducer] +pub fn add_book(ctx: &ReducerContext, isbn: String) { + ctx.db.book().insert(Book { isbn }); +} + +#[spacetimedb::reducer] +pub fn print_books(ctx: &ReducerContext, prefix: String) { + for book in ctx.db.book().iter() { + log::info!("{}: {}", prefix, book.isbn); + } +} +"#; + +/// Tests uploading a module with a schema change that should not require clearing the database. +#[test] +fn test_add_table_auto_migration() { + let mut test = Smoketest::builder().module_code(MODULE_CODE_INIT).build(); + + // Add initial data + test.call("add_person", &["Robert", "Student"]).unwrap(); + test.call("add_person", &["Julie", "Student"]).unwrap(); + test.call("add_person", &["Samantha", "Student"]).unwrap(); + test.call("print_persons", &["BEFORE"]).unwrap(); + + let logs = test.logs(100).unwrap(); + assert!( + logs.iter().any(|l| l.contains("BEFORE: Samantha - Student")), + "Expected Samantha in logs: {:?}", + logs + ); + assert!( + logs.iter().any(|l| l.contains("BEFORE: Julie - Student")), + "Expected Julie in logs: {:?}", + logs + ); + assert!( + logs.iter().any(|l| l.contains("BEFORE: Robert - Student")), + "Expected Robert in logs: {:?}", + logs + ); + + // Update module without clearing database + test.write_module_code(MODULE_CODE_UPDATED).unwrap(); + test.publish_module_clear(false).unwrap(); + + // Add new data with updated schema + test.call("add_person", &["Husserl", "Student"]).unwrap(); + test.call("add_person", &["Husserl", "Professor"]).unwrap(); + test.call("add_book", &["1234567890"]).unwrap(); + test.call("print_persons", &["AFTER_PERSON"]).unwrap(); + test.call("print_books", &["AFTER_BOOK"]).unwrap(); + + let logs = test.logs(100).unwrap(); + assert!( + logs.iter() + .any(|l| l.contains("AFTER_PERSON: Samantha - Student")), + "Expected Samantha in AFTER logs: {:?}", + logs + ); + assert!( + logs.iter() + .any(|l| l.contains("AFTER_PERSON: Julie - Student")), + "Expected Julie in AFTER logs: {:?}", + logs + ); + assert!( + logs.iter() + .any(|l| l.contains("AFTER_PERSON: Robert - Student")), + "Expected Robert in AFTER logs: {:?}", + logs + ); + assert!( + logs.iter() + .any(|l| l.contains("AFTER_PERSON: Husserl - Professor")), + "Expected Husserl Professor in AFTER logs: {:?}", + logs + ); + assert!( + logs.iter().any(|l| l.contains("AFTER_BOOK: 1234567890")), + "Expected book ISBN in AFTER logs: {:?}", + logs + ); +} diff --git a/crates/smoketests/tests/views.rs b/crates/smoketests/tests/views.rs new file mode 100644 index 00000000000..bfd286f0aad --- /dev/null +++ b/crates/smoketests/tests/views.rs @@ -0,0 +1,239 @@ +//! Tests translated from smoketests/tests/views.py + +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE_VIEWS: &str = r#" +use spacetimedb::ViewContext; + +#[derive(Copy, Clone)] +#[spacetimedb::table(name = player_state)] +pub struct PlayerState { + #[primary_key] + id: u64, + #[index(btree)] + level: u64, +} + +#[spacetimedb::view(name = player, public)] +pub fn player(ctx: &ViewContext) -> Option { + ctx.db.player_state().id().find(0u64) +} +"#; + +/// Tests that views populate the st_view_* system tables +#[test] +fn test_st_view_tables() { + let test = Smoketest::builder().module_code(MODULE_CODE_VIEWS).build(); + + test.assert_sql( + "SELECT * FROM st_view", + r#" view_id | view_name | table_id | is_public | is_anonymous +---------+-----------+---------------+-----------+-------------- + 4096 | "player" | (some = 4097) | true | false"#, + ); + + test.assert_sql( + "SELECT * FROM st_view_column", + r#" view_id | col_pos | col_name | col_type +---------+---------+----------+---------- + 4096 | 0 | "id" | 0x0d + 4096 | 1 | "level" | 0x0d"#, + ); +} + +const MODULE_CODE_BROKEN_NAMESPACE: &str = r#" +use spacetimedb::ViewContext; + +#[spacetimedb::table(name = person, public)] +pub struct Person { + name: String, +} + +#[spacetimedb::view(name = person, public)] +pub fn person(ctx: &ViewContext) -> Option { + None +} +"#; + +const MODULE_CODE_BROKEN_RETURN_TYPE: &str = r#" +use spacetimedb::{SpacetimeType, ViewContext}; + +#[derive(SpacetimeType)] +pub enum ABC { + A, + B, + C, +} + +#[spacetimedb::view(name = person, public)] +pub fn person(ctx: &ViewContext) -> Option { + None +} +"#; + +/// Publishing a module should fail if a table and view have the same name +#[test] +fn test_fail_publish_namespace_collision() { + let mut test = Smoketest::builder() + .module_code(MODULE_CODE_BROKEN_NAMESPACE) + .autopublish(false) + .build(); + + let result = test.publish_module(); + assert!( + result.is_err(), + "Expected publish to fail when table and view have same name" + ); +} + +/// Publishing a module should fail if the inner return type is not a product type +#[test] +fn test_fail_publish_wrong_return_type() { + let mut test = Smoketest::builder() + .module_code(MODULE_CODE_BROKEN_RETURN_TYPE) + .autopublish(false) + .build(); + + let result = test.publish_module(); + assert!( + result.is_err(), + "Expected publish to fail when view return type is not a product type" + ); +} + +const MODULE_CODE_SQL_VIEWS: &str = r#" +use spacetimedb::{AnonymousViewContext, ReducerContext, Table, ViewContext}; + +#[derive(Copy, Clone)] +#[spacetimedb::table(name = player_state)] +#[spacetimedb::table(name = player_level)] +pub struct PlayerState { + #[primary_key] + id: u64, + #[index(btree)] + level: u64, +} + +#[derive(Clone)] +#[spacetimedb::table(name = player_info, index(name=age_level_index, btree(columns = [age, level])))] +pub struct PlayerInfo { + #[primary_key] + id: u64, + age: u64, + level: u64, +} + +#[spacetimedb::reducer] +pub fn add_player_level(ctx: &ReducerContext, id: u64, level: u64) { + ctx.db.player_level().insert(PlayerState { id, level }); +} + +#[spacetimedb::view(name = my_player_and_level, public)] +pub fn my_player_and_level(ctx: &AnonymousViewContext) -> Option { + ctx.db.player_level().id().find(0) +} + +#[spacetimedb::view(name = player_and_level, public)] +pub fn player_and_level(ctx: &AnonymousViewContext) -> Vec { + ctx.db.player_level().level().filter(2u64).collect() +} + +#[spacetimedb::view(name = player, public)] +pub fn player(ctx: &ViewContext) -> Option { + log::info!("player view called"); + ctx.db.player_state().id().find(42) +} + +#[spacetimedb::view(name = player_none, public)] +pub fn player_none(_ctx: &ViewContext) -> Option { + None +} + +#[spacetimedb::view(name = player_vec, public)] +pub fn player_vec(ctx: &ViewContext) -> Vec { + let first = ctx.db.player_state().id().find(42).unwrap(); + let second = PlayerState { id: 7, level: 3 }; + vec![first, second] +} + +#[spacetimedb::view(name = player_info_multi_index, public)] +pub fn player_info_view(ctx: &ViewContext) -> Option { + log::info!("player_info called"); + ctx.db.player_info().age_level_index().filter((25u64, 7u64)).next() +} +"#; + +/// Tests that views can be queried over HTTP SQL +#[test] +fn test_http_sql_views() { + let test = Smoketest::builder() + .module_code(MODULE_CODE_SQL_VIEWS) + .build(); + + // Insert initial data + test.sql("INSERT INTO player_state (id, level) VALUES (42, 7)") + .unwrap(); + + test.assert_sql( + "SELECT * FROM player", + r#" id | level +----+------- + 42 | 7"#, + ); + + test.assert_sql( + "SELECT * FROM player_none", + r#" id | level +----+-------"#, + ); + + test.assert_sql( + "SELECT * FROM player_vec", + r#" id | level +----+------- + 42 | 7 + 7 | 3"#, + ); +} + +/// Tests that anonymous views are updated for reducers +#[test] +fn test_query_anonymous_view_reducer() { + let test = Smoketest::builder() + .module_code(MODULE_CODE_SQL_VIEWS) + .build(); + + test.call("add_player_level", &["0", "1"]).unwrap(); + test.call("add_player_level", &["1", "2"]).unwrap(); + + test.assert_sql( + "SELECT * FROM my_player_and_level", + r#" id | level +----+------- + 0 | 1"#, + ); + + test.assert_sql( + "SELECT * FROM player_and_level", + r#" id | level +----+------- + 1 | 2"#, + ); + + test.call("add_player_level", &["2", "2"]).unwrap(); + + test.assert_sql( + "SELECT * FROM player_and_level", + r#" id | level +----+------- + 1 | 2 + 2 | 2"#, + ); + + test.assert_sql( + "SELECT * FROM player_and_level WHERE id = 2", + r#" id | level +----+------- + 2 | 2"#, + ); +} From 63737353378d44572a3223f9e2c190385b9770ed Mon Sep 17 00:00:00 2001 From: = Date: Fri, 23 Jan 2026 02:11:24 -0500 Subject: [PATCH 010/118] Add rls, energy, permissions smoketest translations Add new_identity() method to support multi-identity tests. Translate tests for: - rls.rs: Row-level security filter tests - energy.rs: Energy balance endpoint test - permissions.rs: Private tables, lifecycle reducers, delete protection --- crates/smoketests/src/lib.rs | 27 ++++++ crates/smoketests/tests/energy.rs | 18 ++++ crates/smoketests/tests/permissions.rs | 126 +++++++++++++++++++++++++ crates/smoketests/tests/rls.rs | 52 ++++++++++ 4 files changed, 223 insertions(+) create mode 100644 crates/smoketests/tests/energy.rs create mode 100644 crates/smoketests/tests/permissions.rs create mode 100644 crates/smoketests/tests/rls.rs diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 715521e8e88..4a39c7338d7 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -457,6 +457,33 @@ impl Smoketest { .collect() } + /// Creates a new identity by logging out and logging back in with a server-issued identity. + /// + /// This is useful for tests that need to test with multiple identities. + pub fn new_identity(&self) -> Result<()> { + // Logout first + let cli_path = ensure_binaries_built(); + Command::new(&cli_path) + .args(["logout", "--server", &self.server_url]) + .output() + .context("Failed to logout")?; + + // Login with server-issued identity + let output = Command::new(&cli_path) + .args(["login", "--server-issued-login", "--server", &self.server_url]) + .output() + .context("Failed to login with new identity")?; + + if !output.status.success() { + bail!( + "Failed to create new identity:\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(()) + } + /// Starts a subscription and waits for N updates (synchronous). /// /// Returns the updates as JSON values. diff --git a/crates/smoketests/tests/energy.rs b/crates/smoketests/tests/energy.rs new file mode 100644 index 00000000000..9021ec27d49 --- /dev/null +++ b/crates/smoketests/tests/energy.rs @@ -0,0 +1,18 @@ +//! Tests translated from smoketests/tests/energy.py + +use regex::Regex; +use spacetimedb_smoketests::Smoketest; + +/// Test getting energy balance. +#[test] +fn test_energy_balance() { + let test = Smoketest::builder().build(); + + let output = test.spacetime(&["energy", "balance"]).unwrap(); + let re = Regex::new(r#"\{"balance":"-?[0-9]+"\}"#).unwrap(); + assert!( + re.is_match(&output), + "Expected energy balance JSON, got: {}", + output + ); +} diff --git a/crates/smoketests/tests/permissions.rs b/crates/smoketests/tests/permissions.rs new file mode 100644 index 00000000000..10d8fbcc39b --- /dev/null +++ b/crates/smoketests/tests/permissions.rs @@ -0,0 +1,126 @@ +//! Tests translated from smoketests/tests/permissions.py + +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE_PRIVATE: &str = r#" +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = secret, private)] +pub struct Secret { + answer: u8, +} + +#[spacetimedb::table(name = common_knowledge, public)] +pub struct CommonKnowledge { + thing: String, +} + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + ctx.db.secret().insert(Secret { answer: 42 }); +} + +#[spacetimedb::reducer] +pub fn do_thing(ctx: &ReducerContext, thing: String) { + ctx.db.secret().insert(Secret { answer: 20 }); + ctx.db.common_knowledge().insert(CommonKnowledge { thing }); +} +"#; + +/// Ensure that a private table can only be queried by the database owner +#[test] +fn test_private_table() { + let test = Smoketest::builder() + .module_code(MODULE_CODE_PRIVATE) + .build(); + + // Owner can query private table + test.assert_sql( + "SELECT * FROM secret", + r#" answer +-------- + 42"#, + ); + + // Switch to a new identity + test.new_identity().unwrap(); + + // Non-owner cannot query private table + let result = test.sql("SELECT * FROM secret"); + assert!( + result.is_err(), + "Expected query on private table to fail for non-owner" + ); + + // Subscribing to the private table fails + let result = test.subscribe(&["SELECT * FROM secret"], 0); + assert!( + result.is_err(), + "Expected subscribe to private table to fail for non-owner" + ); + + // Subscribing to the public table works + let sub = test + .subscribe_background(&["SELECT * FROM common_knowledge"], 1) + .unwrap(); + test.call("do_thing", &["godmorgon"]).unwrap(); + let events = sub.collect().unwrap(); + assert_eq!(events.len(), 1, "Expected 1 update, got {:?}", events); + + let expected = serde_json::json!({ + "common_knowledge": { + "deletes": [], + "inserts": [{"thing": "godmorgon"}] + } + }); + assert_eq!(events[0], expected); +} + +/// Ensure that you cannot delete a database that you do not own +#[test] +fn test_cannot_delete_others_database() { + let test = Smoketest::builder().build(); + + let identity = test.database_identity.as_ref().unwrap().clone(); + + // Switch to a new identity + test.new_identity().unwrap(); + + // Try to delete the database - should fail + let result = test.spacetime(&["delete", &identity, "--yes"]); + assert!( + result.is_err(), + "Expected delete to fail for non-owner" + ); +} + +const MODULE_CODE_LIFECYCLE: &str = r#" +#[spacetimedb::reducer(init)] +fn lifecycle_init(_ctx: &spacetimedb::ReducerContext) {} + +#[spacetimedb::reducer(client_connected)] +fn lifecycle_client_connected(_ctx: &spacetimedb::ReducerContext) {} + +#[spacetimedb::reducer(client_disconnected)] +fn lifecycle_client_disconnected(_ctx: &spacetimedb::ReducerContext) {} +"#; + +/// Ensure that lifecycle reducers (init, on_connect, etc) can't be called directly +#[test] +fn test_lifecycle_reducers_cant_be_called() { + let test = Smoketest::builder() + .module_code(MODULE_CODE_LIFECYCLE) + .build(); + + let lifecycle_kinds = ["init", "client_connected", "client_disconnected"]; + + for kind in lifecycle_kinds { + let reducer_name = format!("lifecycle_{}", kind); + let result = test.call(&reducer_name, &[]); + assert!( + result.is_err(), + "Expected call to lifecycle reducer '{}' to fail", + reducer_name + ); + } +} diff --git a/crates/smoketests/tests/rls.rs b/crates/smoketests/tests/rls.rs new file mode 100644 index 00000000000..d60f774159d --- /dev/null +++ b/crates/smoketests/tests/rls.rs @@ -0,0 +1,52 @@ +//! Tests translated from smoketests/tests/rls.py + +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE: &str = r#" +use spacetimedb::{Identity, ReducerContext, Table}; + +#[spacetimedb::table(name = users, public)] +pub struct Users { + name: String, + identity: Identity, +} + +#[spacetimedb::client_visibility_filter] +const USER_FILTER: spacetimedb::Filter = spacetimedb::Filter::Sql( + "SELECT * FROM users WHERE identity = :sender" +); + +#[spacetimedb::reducer] +pub fn add_user(ctx: &ReducerContext, name: String) { + ctx.db.users().insert(Users { name, identity: ctx.sender }); +} +"#; + +/// Tests for querying tables with RLS rules +#[test] +fn test_rls_rules() { + let test = Smoketest::builder().module_code(MODULE_CODE).build(); + + // Insert a user for Alice (current identity) + test.call("add_user", &["Alice"]).unwrap(); + + // Create a new identity for Bob + test.new_identity().unwrap(); + test.call("add_user", &["Bob"]).unwrap(); + + // Query the users table using Bob's identity - should only see Bob + test.assert_sql( + "SELECT name FROM users", + r#" name +------- + "Bob""#, + ); + + // Create another new identity - should see no users + test.new_identity().unwrap(); + test.assert_sql( + "SELECT name FROM users", + r#" name +------"#, + ); +} From 60cba8d6e0bf6af4e6b7c2b710f19877cc4592b9 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 23 Jan 2026 02:16:25 -0500 Subject: [PATCH 011/118] Add new_user_flow and servers smoketest translations Translate tests for: - new_user_flow.rs: Basic publish/call/SQL workflow - servers.rs: Server add/list/edit commands --- crates/smoketests/tests/new_user_flow.rs | 66 +++++++++++++++++ crates/smoketests/tests/servers.rs | 91 ++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 crates/smoketests/tests/new_user_flow.rs create mode 100644 crates/smoketests/tests/servers.rs diff --git a/crates/smoketests/tests/new_user_flow.rs b/crates/smoketests/tests/new_user_flow.rs new file mode 100644 index 00000000000..516d5c104ff --- /dev/null +++ b/crates/smoketests/tests/new_user_flow.rs @@ -0,0 +1,66 @@ +//! Tests translated from smoketests/tests/new_user_flow.py + +use spacetimedb_smoketests::Smoketest; + +const MODULE_CODE: &str = r#" +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person)] +pub struct Person { + name: String +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { name }); +} + +#[spacetimedb::reducer] +pub fn say_hello(ctx: &ReducerContext) { + for person in ctx.db.person().iter() { + log::info!("Hello, {}!", person.name); + } + log::info!("Hello, World!"); +} +"#; + +/// Test the entirety of the new user flow. +#[test] +fn test_new_user_flow() { + let mut test = Smoketest::builder() + .module_code(MODULE_CODE) + .autopublish(false) + .build(); + + // Create a new identity and publish + test.new_identity().unwrap(); + test.publish_module().unwrap(); + + // Calling our database + test.call("say_hello", &[]).unwrap(); + let logs = test.logs(2).unwrap(); + assert!( + logs.iter().any(|l| l.contains("Hello, World!")), + "Expected 'Hello, World!' in logs: {:?}", + logs + ); + + // Calling functions with arguments + test.call("add", &["Tyler"]).unwrap(); + test.call("say_hello", &[]).unwrap(); + + let logs = test.logs(5).unwrap(); + let hello_world_count = logs.iter().filter(|l| l.contains("Hello, World!")).count(); + let hello_tyler_count = logs.iter().filter(|l| l.contains("Hello, Tyler!")).count(); + + assert_eq!(hello_world_count, 2, "Expected 2 'Hello, World!' in logs"); + assert_eq!(hello_tyler_count, 1, "Expected 1 'Hello, Tyler!' in logs"); + + // Query via SQL + test.assert_sql( + "SELECT * FROM person", + r#" name +--------- + "Tyler""#, + ); +} diff --git a/crates/smoketests/tests/servers.rs b/crates/smoketests/tests/servers.rs new file mode 100644 index 00000000000..7f6176076ba --- /dev/null +++ b/crates/smoketests/tests/servers.rs @@ -0,0 +1,91 @@ +//! Tests translated from smoketests/tests/servers.py + +use regex::Regex; +use spacetimedb_smoketests::Smoketest; + +/// Verify that we can add and list server configurations +#[test] +fn test_servers() { + let test = Smoketest::builder().autopublish(false).build(); + + // Add a test server + let output = test + .spacetime(&[ + "server", + "add", + "--url", + "https://testnet.spacetimedb.com", + "testnet", + "--no-fingerprint", + ]) + .unwrap(); + + assert!( + output.contains("testnet.spacetimedb.com"), + "Expected host in output: {}", + output + ); + + // List servers + let servers = test.spacetime(&["server", "list"]).unwrap(); + + let testnet_re = Regex::new(r"(?m)^\s*testnet\.spacetimedb\.com\s+https\s+testnet\s*$").unwrap(); + assert!( + testnet_re.is_match(&servers), + "Expected testnet in server list: {}", + servers + ); + + // Check fingerprint commands + let output = test + .spacetime(&["server", "fingerprint", &test.server_url, "-y"]) + .unwrap(); + // The exact message may vary, just check it doesn't error + assert!( + output.contains("fingerprint") || output.contains("Fingerprint"), + "Expected fingerprint message: {}", + output + ); +} + +/// Verify that we can edit server configurations +#[test] +fn test_edit_server() { + let test = Smoketest::builder().autopublish(false).build(); + + // Add a server to edit + test.spacetime(&[ + "server", + "add", + "--url", + "https://foo.com", + "foo", + "--no-fingerprint", + ]) + .unwrap(); + + // Edit the server + test.spacetime(&[ + "server", + "edit", + "foo", + "--url", + "https://edited-testnet.spacetimedb.com", + "--new-name", + "edited-testnet", + "--no-fingerprint", + "--yes", + ]) + .unwrap(); + + // Verify the edit + let servers = test.spacetime(&["server", "list"]).unwrap(); + let edited_re = + Regex::new(r"(?m)^\s*edited-testnet\.spacetimedb\.com\s+https\s+edited-testnet\s*$") + .unwrap(); + assert!( + edited_re.is_match(&servers), + "Expected edited server in list: {}", + servers + ); +} From afb0a71502e8202518eee92439d4a7b4a78a8d5f Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Fri, 23 Jan 2026 02:50:04 -0600 Subject: [PATCH 012/118] cargo fmt + don't block on lints for now --- .github/workflows/ci.yml | 3 ++- crates/smoketests/tests/auto_migration.rs | 19 ++++++------------- crates/smoketests/tests/energy.rs | 6 +----- crates/smoketests/tests/new_user_flow.rs | 5 +---- crates/smoketests/tests/permissions.rs | 18 ++++-------------- crates/smoketests/tests/servers.rs | 15 +++------------ crates/smoketests/tests/views.rs | 11 +++-------- 7 files changed, 20 insertions(+), 57 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 349624688f2..beef3900a4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,8 @@ concurrency: jobs: docker_smoketests: - needs: [lints] + # TODO: before merging re-enable this + # needs: [lints] name: Smoketests strategy: matrix: diff --git a/crates/smoketests/tests/auto_migration.rs b/crates/smoketests/tests/auto_migration.rs index bf3109be822..bac8b14e68e 100644 --- a/crates/smoketests/tests/auto_migration.rs +++ b/crates/smoketests/tests/auto_migration.rs @@ -48,13 +48,10 @@ pub fn print_persons(ctx: &ReducerContext, prefix: String) { /// Tests that a module with invalid schema changes cannot be published without -c or a migration. #[test] fn test_reject_schema_changes() { - let mut test = Smoketest::builder() - .module_code(MODULE_CODE_SIMPLE) - .build(); + let mut test = Smoketest::builder().module_code(MODULE_CODE_SIMPLE).build(); // Try to update with incompatible schema (adding column without default) - test.write_module_code(MODULE_CODE_UPDATED_INCOMPATIBLE) - .unwrap(); + test.write_module_code(MODULE_CODE_UPDATED_INCOMPATIBLE).unwrap(); let result = test.publish_module_clear(false); assert!( @@ -242,26 +239,22 @@ fn test_add_table_auto_migration() { let logs = test.logs(100).unwrap(); assert!( - logs.iter() - .any(|l| l.contains("AFTER_PERSON: Samantha - Student")), + logs.iter().any(|l| l.contains("AFTER_PERSON: Samantha - Student")), "Expected Samantha in AFTER logs: {:?}", logs ); assert!( - logs.iter() - .any(|l| l.contains("AFTER_PERSON: Julie - Student")), + logs.iter().any(|l| l.contains("AFTER_PERSON: Julie - Student")), "Expected Julie in AFTER logs: {:?}", logs ); assert!( - logs.iter() - .any(|l| l.contains("AFTER_PERSON: Robert - Student")), + logs.iter().any(|l| l.contains("AFTER_PERSON: Robert - Student")), "Expected Robert in AFTER logs: {:?}", logs ); assert!( - logs.iter() - .any(|l| l.contains("AFTER_PERSON: Husserl - Professor")), + logs.iter().any(|l| l.contains("AFTER_PERSON: Husserl - Professor")), "Expected Husserl Professor in AFTER logs: {:?}", logs ); diff --git a/crates/smoketests/tests/energy.rs b/crates/smoketests/tests/energy.rs index 9021ec27d49..b25abd8e505 100644 --- a/crates/smoketests/tests/energy.rs +++ b/crates/smoketests/tests/energy.rs @@ -10,9 +10,5 @@ fn test_energy_balance() { let output = test.spacetime(&["energy", "balance"]).unwrap(); let re = Regex::new(r#"\{"balance":"-?[0-9]+"\}"#).unwrap(); - assert!( - re.is_match(&output), - "Expected energy balance JSON, got: {}", - output - ); + assert!(re.is_match(&output), "Expected energy balance JSON, got: {}", output); } diff --git a/crates/smoketests/tests/new_user_flow.rs b/crates/smoketests/tests/new_user_flow.rs index 516d5c104ff..7cc13388900 100644 --- a/crates/smoketests/tests/new_user_flow.rs +++ b/crates/smoketests/tests/new_user_flow.rs @@ -27,10 +27,7 @@ pub fn say_hello(ctx: &ReducerContext) { /// Test the entirety of the new user flow. #[test] fn test_new_user_flow() { - let mut test = Smoketest::builder() - .module_code(MODULE_CODE) - .autopublish(false) - .build(); + let mut test = Smoketest::builder().module_code(MODULE_CODE).autopublish(false).build(); // Create a new identity and publish test.new_identity().unwrap(); diff --git a/crates/smoketests/tests/permissions.rs b/crates/smoketests/tests/permissions.rs index 10d8fbcc39b..76f8877fc6f 100644 --- a/crates/smoketests/tests/permissions.rs +++ b/crates/smoketests/tests/permissions.rs @@ -30,9 +30,7 @@ pub fn do_thing(ctx: &ReducerContext, thing: String) { /// Ensure that a private table can only be queried by the database owner #[test] fn test_private_table() { - let test = Smoketest::builder() - .module_code(MODULE_CODE_PRIVATE) - .build(); + let test = Smoketest::builder().module_code(MODULE_CODE_PRIVATE).build(); // Owner can query private table test.assert_sql( @@ -47,10 +45,7 @@ fn test_private_table() { // Non-owner cannot query private table let result = test.sql("SELECT * FROM secret"); - assert!( - result.is_err(), - "Expected query on private table to fail for non-owner" - ); + assert!(result.is_err(), "Expected query on private table to fail for non-owner"); // Subscribing to the private table fails let result = test.subscribe(&["SELECT * FROM secret"], 0); @@ -88,10 +83,7 @@ fn test_cannot_delete_others_database() { // Try to delete the database - should fail let result = test.spacetime(&["delete", &identity, "--yes"]); - assert!( - result.is_err(), - "Expected delete to fail for non-owner" - ); + assert!(result.is_err(), "Expected delete to fail for non-owner"); } const MODULE_CODE_LIFECYCLE: &str = r#" @@ -108,9 +100,7 @@ fn lifecycle_client_disconnected(_ctx: &spacetimedb::ReducerContext) {} /// Ensure that lifecycle reducers (init, on_connect, etc) can't be called directly #[test] fn test_lifecycle_reducers_cant_be_called() { - let test = Smoketest::builder() - .module_code(MODULE_CODE_LIFECYCLE) - .build(); + let test = Smoketest::builder().module_code(MODULE_CODE_LIFECYCLE).build(); let lifecycle_kinds = ["init", "client_connected", "client_disconnected"]; diff --git a/crates/smoketests/tests/servers.rs b/crates/smoketests/tests/servers.rs index 7f6176076ba..a6b6d06d77c 100644 --- a/crates/smoketests/tests/servers.rs +++ b/crates/smoketests/tests/servers.rs @@ -54,15 +54,8 @@ fn test_edit_server() { let test = Smoketest::builder().autopublish(false).build(); // Add a server to edit - test.spacetime(&[ - "server", - "add", - "--url", - "https://foo.com", - "foo", - "--no-fingerprint", - ]) - .unwrap(); + test.spacetime(&["server", "add", "--url", "https://foo.com", "foo", "--no-fingerprint"]) + .unwrap(); // Edit the server test.spacetime(&[ @@ -80,9 +73,7 @@ fn test_edit_server() { // Verify the edit let servers = test.spacetime(&["server", "list"]).unwrap(); - let edited_re = - Regex::new(r"(?m)^\s*edited-testnet\.spacetimedb\.com\s+https\s+edited-testnet\s*$") - .unwrap(); + let edited_re = Regex::new(r"(?m)^\s*edited-testnet\.spacetimedb\.com\s+https\s+edited-testnet\s*$").unwrap(); assert!( edited_re.is_match(&servers), "Expected edited server in list: {}", diff --git a/crates/smoketests/tests/views.rs b/crates/smoketests/tests/views.rs index bfd286f0aad..d3cf7c4eadf 100644 --- a/crates/smoketests/tests/views.rs +++ b/crates/smoketests/tests/views.rs @@ -166,13 +166,10 @@ pub fn player_info_view(ctx: &ViewContext) -> Option { /// Tests that views can be queried over HTTP SQL #[test] fn test_http_sql_views() { - let test = Smoketest::builder() - .module_code(MODULE_CODE_SQL_VIEWS) - .build(); + let test = Smoketest::builder().module_code(MODULE_CODE_SQL_VIEWS).build(); // Insert initial data - test.sql("INSERT INTO player_state (id, level) VALUES (42, 7)") - .unwrap(); + test.sql("INSERT INTO player_state (id, level) VALUES (42, 7)").unwrap(); test.assert_sql( "SELECT * FROM player", @@ -199,9 +196,7 @@ fn test_http_sql_views() { /// Tests that anonymous views are updated for reducers #[test] fn test_query_anonymous_view_reducer() { - let test = Smoketest::builder() - .module_code(MODULE_CODE_SQL_VIEWS) - .build(); + let test = Smoketest::builder().module_code(MODULE_CODE_SQL_VIEWS).build(); test.call("add_player_level", &["0", "1"]).unwrap(); test.call("add_player_level", &["1", "2"]).unwrap(); From 4f4aedecb90d3b801868a44412258f07349b7e9a Mon Sep 17 00:00:00 2001 From: = Date: Fri, 23 Jan 2026 11:39:15 -0500 Subject: [PATCH 013/118] Fix smoketest CLI config isolation and identity switching - Add --config-path to spacetime_local() for test isolation - Fix new_identity() to not pass server arg to logout (matches Python) - Insert --server flag before -- separator in spacetime_cmd() - Update servers.rs to use spacetime_local() for local-only commands - Simplify test files by removing redundant publish_module() calls All 56 smoketests now pass. --- crates/smoketests/src/lib.rs | 72 ++++++++++++++++++++++++------ crates/smoketests/tests/servers.rs | 35 ++++++++++----- 2 files changed, 81 insertions(+), 26 deletions(-) diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 4a39c7338d7..161fbf01200 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -73,6 +73,8 @@ pub struct Smoketest { pub database_identity: Option, /// The server URL (e.g., "http://127.0.0.1:3000"). pub server_url: String, + /// Path to the test-specific CLI config file (isolates tests from user config). + pub config_path: std::path::PathBuf, } /// Builder for creating `Smoketest` instances. @@ -185,11 +187,13 @@ pub fn noop(_ctx: &ReducerContext) {} eprintln!("[TIMING] project setup: {:?}", project_setup_start.elapsed()); let server_url = guard.host_url.clone(); + let config_path = project_dir.path().join("config.toml"); let mut smoketest = Smoketest { guard, project_dir, database_identity: None, server_url, + config_path, }; if self.autopublish { @@ -210,15 +214,25 @@ impl Smoketest { /// Runs a spacetime CLI command with the configured server. /// /// Returns the command output. The command is run but not yet asserted. - /// The `--server` flag is automatically inserted after the first argument (the subcommand). + /// The `--server` flag is automatically inserted before any `--` separator, + /// or at the end if no separator exists. pub fn spacetime_cmd(&self, args: &[&str]) -> Output { let start = Instant::now(); let cli_path = ensure_binaries_built(); let mut cmd = Command::new(&cli_path); - // Insert --server after the subcommand - if let Some((subcommand, rest)) = args.split_first() { - cmd.arg(subcommand).arg("--server").arg(&self.server_url).args(rest); + // Use test-specific config path to avoid polluting user's config + cmd.arg("--config-path").arg(&self.config_path); + + // Insert --server before any "--" separator, or at the end + // This ensures --server is processed as a flag, not a positional arg + if let Some(pos) = args.iter().position(|&a| a == "--") { + cmd.args(&args[..pos]) + .arg("--server") + .arg(&self.server_url) + .args(&args[pos..]); + } else { + cmd.args(args).arg("--server").arg(&self.server_url); } let output = cmd @@ -249,11 +263,15 @@ impl Smoketest { /// Runs a spacetime CLI command without adding the --server flag. /// - /// Use this for local-only commands like `generate` that don't need a server connection. + /// Use this for local-only commands like `generate` or `server` subcommands + /// that don't need a server connection. + /// Still uses --config-path to isolate test config from user config. pub fn spacetime_local(&self, args: &[&str]) -> Result { let start = Instant::now(); let cli_path = ensure_binaries_built(); let output = Command::new(&cli_path) + .arg("--config-path") + .arg(&self.config_path) .args(args) .current_dir(self.project_dir.path()) .output() @@ -461,22 +479,31 @@ impl Smoketest { /// /// This is useful for tests that need to test with multiple identities. pub fn new_identity(&self) -> Result<()> { - // Logout first let cli_path = ensure_binaries_built(); - Command::new(&cli_path) - .args(["logout", "--server", &self.server_url]) - .output() - .context("Failed to logout")?; + let config_path_str = self.config_path.to_str().unwrap(); + + // Logout first (ignore errors - may not be logged in) + let _ = Command::new(&cli_path) + .args(["--config-path", config_path_str, "logout"]) + .output(); // Login with server-issued identity + // Format: login --server-issued-login let output = Command::new(&cli_path) - .args(["login", "--server-issued-login", "--server", &self.server_url]) + .args([ + "--config-path", + config_path_str, + "login", + "--server-issued-login", + &self.server_url, + ]) .output() .context("Failed to login with new identity")?; if !output.status.success() { bail!( - "Failed to create new identity:\nstderr: {}", + "Failed to create new identity:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } @@ -501,10 +528,21 @@ impl Smoketest { fn subscribe_opts(&self, queries: &[&str], n: usize, confirmed: bool) -> Result> { let start = Instant::now(); let identity = self.database_identity.as_ref().context("No database published")?; + let config_path_str = self.config_path.to_str().unwrap(); let cli_path = ensure_binaries_built(); let mut cmd = Command::new(&cli_path); - let mut args = vec!["subscribe", "--server", &self.server_url, identity, "-t", "30", "-n"]; + let mut args = vec![ + "--config-path", + config_path_str, + "subscribe", + "--server", + &self.server_url, + identity, + "-t", + "30", + "-n", + ]; let n_str = n.to_string(); args.push(&n_str); args.push("--print-initial-update"); @@ -521,7 +559,10 @@ impl Smoketest { eprintln!("[TIMING] subscribe (n={}): {:?}", n, start.elapsed()); if !output.status.success() { - bail!("subscribe failed:\nstderr: {}", String::from_utf8_lossy(&output.stderr)); + bail!( + "subscribe failed:\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); } let stdout = String::from_utf8_lossy(&output.stdout); @@ -558,7 +599,10 @@ impl Smoketest { let cli_path = ensure_binaries_built(); let mut cmd = Command::new(&cli_path); // Use --print-initial-update so we know when subscription is established + let config_path_str = self.config_path.to_str().unwrap().to_string(); let mut args = vec![ + "--config-path".to_string(), + config_path_str, "subscribe".to_string(), "--server".to_string(), self.server_url.clone(), diff --git a/crates/smoketests/tests/servers.rs b/crates/smoketests/tests/servers.rs index a6b6d06d77c..b33e744a867 100644 --- a/crates/smoketests/tests/servers.rs +++ b/crates/smoketests/tests/servers.rs @@ -8,9 +8,9 @@ use spacetimedb_smoketests::Smoketest; fn test_servers() { let test = Smoketest::builder().autopublish(false).build(); - // Add a test server + // Add a test server (local-only command, no --server flag needed) let output = test - .spacetime(&[ + .spacetime_local(&[ "server", "add", "--url", @@ -26,8 +26,8 @@ fn test_servers() { output ); - // List servers - let servers = test.spacetime(&["server", "list"]).unwrap(); + // List servers (local-only command) + let servers = test.spacetime_local(&["server", "list"]).unwrap(); let testnet_re = Regex::new(r"(?m)^\s*testnet\.spacetimedb\.com\s+https\s+testnet\s*$").unwrap(); assert!( @@ -36,9 +36,20 @@ fn test_servers() { servers ); - // Check fingerprint commands + // Add the local test server to the config so we can check its fingerprint + test.spacetime_local(&[ + "server", + "add", + "--url", + &test.server_url, + "test-local", + "--no-fingerprint", + ]) + .unwrap(); + + // Check fingerprint commands (local-only command) let output = test - .spacetime(&["server", "fingerprint", &test.server_url, "-y"]) + .spacetime_local(&["server", "fingerprint", "test-local", "-y"]) .unwrap(); // The exact message may vary, just check it doesn't error assert!( @@ -53,12 +64,12 @@ fn test_servers() { fn test_edit_server() { let test = Smoketest::builder().autopublish(false).build(); - // Add a server to edit - test.spacetime(&["server", "add", "--url", "https://foo.com", "foo", "--no-fingerprint"]) + // Add a server to edit (local-only command) + test.spacetime_local(&["server", "add", "--url", "https://foo.com", "foo", "--no-fingerprint"]) .unwrap(); - // Edit the server - test.spacetime(&[ + // Edit the server (local-only command) + test.spacetime_local(&[ "server", "edit", "foo", @@ -71,8 +82,8 @@ fn test_edit_server() { ]) .unwrap(); - // Verify the edit - let servers = test.spacetime(&["server", "list"]).unwrap(); + // Verify the edit (local-only command) + let servers = test.spacetime_local(&["server", "list"]).unwrap(); let edited_re = Regex::new(r"(?m)^\s*edited-testnet\.spacetimedb\.com\s+https\s+edited-testnet\s*$").unwrap(); assert!( edited_re.is_match(&servers), From dafa00490f0241f221d88332c00692de6a212fa6 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 10:16:51 -0800 Subject: [PATCH 014/118] [tyler/translate-smoketests]: lints --- crates/smoketests/src/lib.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 161fbf01200..6df4ae4a946 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -559,10 +559,7 @@ impl Smoketest { eprintln!("[TIMING] subscribe (n={}): {:?}", n, start.elapsed()); if !output.status.success() { - bail!( - "subscribe failed:\nstderr: {}", - String::from_utf8_lossy(&output.stderr) - ); + bail!("subscribe failed:\nstderr: {}", String::from_utf8_lossy(&output.stderr)); } let stdout = String::from_utf8_lossy(&output.stdout); From 8b6506bf5e0240ddbf790edd42ae0afed34186ec Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 10:19:55 -0800 Subject: [PATCH 015/118] [tyler/translate-smoketests]: more lints --- crates/guard/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 4a211868bc3..82b7181ee29 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -48,7 +48,7 @@ pub fn ensure_binaries_built() -> PathBuf { // CARGO_ENCODED_RUSTFLAGS that differ from a normal build, // causing the child cargo to think it needs to recompile. let mut cmd = Command::new("cargo"); - cmd.args(&args).current_dir(&workspace_root); + cmd.args(&args).current_dir(workspace_root); for (key, _) in env::vars() { if key.starts_with("CARGO") && key != "CARGO_HOME" { cmd.env_remove(&key); From 447413b2f21a743a7a1a25faa403545713cfcebd Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 10:22:35 -0800 Subject: [PATCH 016/118] [tyler/translate-smoketests]: more lints --- crates/smoketests/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 6df4ae4a946..3d691ac06aa 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -1,3 +1,4 @@ +#![allow(clippy::disallowed_macros)] //! Rust smoketest infrastructure for SpacetimeDB. //! //! This crate provides utilities for writing end-to-end tests that compile and publish From c18ff5c4ec3cf8f7b831c33f6b5e0487df018293 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 10:25:47 -0800 Subject: [PATCH 017/118] [tyler/translate-smoketests]: more lints --- crates/smoketests/tests/namespaces.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/smoketests/tests/namespaces.rs b/crates/smoketests/tests/namespaces.rs index 367cbe5edd3..de63c4d952c 100644 --- a/crates/smoketests/tests/namespaces.rs +++ b/crates/smoketests/tests/namespaces.rs @@ -50,7 +50,7 @@ fn count_matches(dir: &Path, needle: &str) -> usize { let path = entry.path(); if path.is_dir() { count += count_matches(&path, needle); - } else if path.extension().map_or(false, |ext| ext == "cs") { + } else if path.extension().is_some_and(|ext| ext == "cs") { if let Ok(contents) = fs::read_to_string(&path) { count += contents.matches(needle).count(); } From 95308f279721406d1119f10a977c8e39205f17d7 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 10:39:52 -0800 Subject: [PATCH 018/118] [tyler/translate-smoketests]: update ci stuff --- .github/workflows/ci.yml | 8 +------- tools/ci/src/main.rs | 8 +++----- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index beef3900a4f..a065cd013b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,14 +112,8 @@ jobs: - uses: actions/setup-python@v5 with: { python-version: "3.12" } if: runner.os == 'Windows' - - name: Install python deps - run: python -m pip install -r smoketests/requirements.txt - name: Run smoketests - # Note: clear_database and replication only work in private - run: cargo ci smoketests -- ${{ matrix.smoketest_args }} -x clear_database replication teams - - name: Stop containers (Linux) - if: always() && runner.os == 'Linux' - run: docker compose -f .github/docker-compose.yml down + run: cargo ci smoketests test: needs: [lints] diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index 0ee219f8d7e..1ed28087b0a 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -398,13 +398,11 @@ fn main() -> Result<()> { } Some(CiCmd::Smoketests { args: smoketest_args }) => { - let python = infer_python(); cmd( - python, - ["-m", "smoketests"] + "cargo", + ["test", "-p", "spacetimedb-smoketests"] .into_iter() - .map(|s| s.to_string()) - .chain(smoketest_args), + .chain(smoketest_args.clone()), ) .run()?; } From ed2735e11d0f1236be674e0c8ee460711fe13c01 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 10:40:36 -0800 Subject: [PATCH 019/118] [tyler/translate-smoketests]: fix build --- tools/ci/src/main.rs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index 1ed28087b0a..d744094c55e 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -219,15 +219,6 @@ fn run_all_clap_subcommands(skips: &[String]) -> Result<()> { Ok(()) } -fn infer_python() -> String { - let py3_available = cmd!("python3", "--version").run().is_ok(); - if py3_available { - "python3".to_string() - } else { - "python".to_string() - } -} - fn main() -> Result<()> { env_logger::init(); @@ -402,7 +393,7 @@ fn main() -> Result<()> { "cargo", ["test", "-p", "spacetimedb-smoketests"] .into_iter() - .chain(smoketest_args.clone()), + .chain(smoketest_args.iter().map(|s| s.as_str()).clone()), ) .run()?; } From fdba9e51a7800c6f9cfaf5fa652009ea59fbf2b2 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 10:47:00 -0800 Subject: [PATCH 020/118] [tyler/translate-smoketests]: CI fixes? --- .github/workflows/ci.yml | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a065cd013b5..691b9a01db5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,18 +85,7 @@ jobs: if: runner.os == 'Windows' run: choco install psql -y --no-progress shell: powershell - - name: Build crates - run: cargo build -p spacetimedb-cli -p spacetimedb-standalone -p spacetimedb-update - - name: Start Docker daemon - if: runner.os == 'Linux' - run: /usr/local/bin/start-docker.sh - - - name: Build and start database (Linux) - if: runner.os == 'Linux' - run: | - # Our .dockerignore omits `target`, which our CI Dockerfile needs. - rm .dockerignore - docker compose -f .github/docker-compose.yml up -d + - name: Build and start database (Windows) if: runner.os == 'Windows' run: | @@ -109,9 +98,21 @@ jobs: # the sdk-manifests on windows-latest are messed up, so we need to update them dotnet workload config --update-mode manifests dotnet workload update - - uses: actions/setup-python@v5 - with: { python-version: "3.12" } - if: runner.os == 'Windows' + + # This step shouldn't be needed, but somehow we end up with caches that are missing librusty_v8.a. + # ChatGPT suspects that this could be due to different build invocations using the same target dir, + # and this makes sense to me because we only see it in this job where we mix `cargo build -p` with + # `cargo build --manifest-path` (which apparently build different dependency trees). + # However, we've been unable to fix it so... /shrug + - name: Check v8 outputs + run: | + find "${CARGO_TARGET_DIR}"/ -type f | grep '[/_]v8' || true + if ! [ -f "${CARGO_TARGET_DIR}"/debug/gn_out/obj/librusty_v8.a ]; then + echo "Could not find v8 output file librusty_v8.a; rebuilding manually." + cargo clean -p v8 || true + cargo build -p v8 + fi + - name: Run smoketests run: cargo ci smoketests From 09b53ded90b608b191b6140150b969db388c4526 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 10:47:11 -0800 Subject: [PATCH 021/118] [tyler/translate-smoketests]: ci --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 691b9a01db5..055b8a3439b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,12 +27,10 @@ jobs: runner: [spacetimedb-new-runner, windows-latest] include: - runner: spacetimedb-new-runner - smoketest_args: --docker container: image: localhost:5000/spacetimedb-ci:latest options: --privileged - runner: windows-latest - smoketest_args: --no-build-cli container: null runs-on: ${{ matrix.runner }} container: ${{ matrix.container }} From 017a74405205de90cd77e0f79ab39eb9ac4ecfcd Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 10:56:52 -0800 Subject: [PATCH 022/118] [tyler/translate-smoketests]: windows CI --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 055b8a3439b..422efba0234 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,14 +84,13 @@ jobs: run: choco install psql -y --no-progress shell: powershell - - name: Build and start database (Windows) + - name: Update dotnet workloads if: runner.os == 'Windows' run: | # Fail properly if any individual command fails $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true - Start-Process target/debug/spacetimedb-cli.exe -ArgumentList 'start --pg-port 5432' cd modules # the sdk-manifests on windows-latest are messed up, so we need to update them dotnet workload config --update-mode manifests From 8ebf8e631513a4af71bca2b87f693510c91ab969 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 11:01:01 -0800 Subject: [PATCH 023/118] [tyler/translate-smoketests]: fix windows ci --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 422efba0234..1f8e69df655 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -719,6 +719,7 @@ jobs: # `cargo build --manifest-path` (which apparently build different dependency trees). # However, we've been unable to fix it so... /shrug - name: Check v8 outputs + shell: bash run: | find "${CARGO_TARGET_DIR}"/ -type f | grep '[/_]v8' || true if ! [ -f "${CARGO_TARGET_DIR}"/debug/gn_out/obj/librusty_v8.a ]; then From 17cfed7ce0cc0460ffa3d1e3d46bd25d72cf4bc8 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 23 Jan 2026 14:06:51 -0500 Subject: [PATCH 024/118] Add quickstart smoketest translation Translate smoketests/tests/quickstart.py to Rust. This test validates that the quickstart documentation is correct by extracting code from markdown docs and running it. - Add parse_quickstart() to parse code blocks from markdown with CRLF handling - Add have_pnpm() to check for pnpm availability - Implement QuickstartTest with support for Rust, C#, and TypeScript servers - Rust test passes; C#/TypeScript skip gracefully if dependencies unavailable --- crates/smoketests/src/lib.rs | 305 ++++++++++- crates/smoketests/tests/quickstart.rs | 697 ++++++++++++++++++++++++++ 2 files changed, 1000 insertions(+), 2 deletions(-) create mode 100644 crates/smoketests/tests/quickstart.rs diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 3d691ac06aa..20282ec26ea 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -41,6 +41,7 @@ use std::env; use std::fs; use std::path::PathBuf; use std::process::{Command, Output, Stdio}; +use std::sync::OnceLock; use std::time::Instant; /// Helper macro for timing operations and printing results @@ -55,7 +56,7 @@ macro_rules! timed { } /// Returns the workspace root directory. -fn workspace_root() -> PathBuf { +pub fn workspace_root() -> PathBuf { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); manifest_dir .parent() @@ -64,6 +65,128 @@ fn workspace_root() -> PathBuf { .to_path_buf() } +/// Generates a random lowercase alphabetic string suitable for database names. +pub fn random_string() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + // Convert to base-26 using lowercase letters only (a-z) + let mut result = String::with_capacity(20); + let mut n = timestamp; + while n > 0 || result.len() < 10 { + let c = (b'a' + (n % 26) as u8) as char; + result.push(c); + n /= 26; + } + result +} + +/// Returns true if dotnet 8.0+ is available on the system. +pub fn have_dotnet() -> bool { + static HAVE_DOTNET: OnceLock = OnceLock::new(); + *HAVE_DOTNET.get_or_init(|| { + Command::new("dotnet") + .args(["--list-sdks"]) + .output() + .map(|output| { + if !output.status.success() { + return false; + } + let stdout = String::from_utf8_lossy(&output.stdout); + // Check for dotnet 8.0 or higher + stdout.lines().any(|line| { + line.starts_with("8.") || line.starts_with("9.") || line.starts_with("10.") + }) + }) + .unwrap_or(false) + }) +} + +/// Returns true if psql (PostgreSQL client) is available on the system. +pub fn have_psql() -> bool { + static HAVE_PSQL: OnceLock = OnceLock::new(); + *HAVE_PSQL.get_or_init(|| { + Command::new("psql") + .args(["--version"]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) + }) +} + +/// Returns true if pnpm is available on the system. +pub fn have_pnpm() -> bool { + static HAVE_PNPM: OnceLock = OnceLock::new(); + *HAVE_PNPM.get_or_init(|| { + Command::new("pnpm") + .args(["--version"]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) + }) +} + +/// Parse code blocks from quickstart markdown documentation. +/// Extracts code blocks with the specified language tag. +/// +/// - `language`: "rust", "csharp", or "typescript" +/// - `module_name`: The name to replace "quickstart-chat" with +/// - `server`: If true, look for server code blocks (e.g. "rust server"), else client blocks +pub fn parse_quickstart(doc_content: &str, language: &str, module_name: &str, server: bool) -> String { + // Normalize line endings to Unix style (LF) for consistent regex matching + let doc_content = doc_content.replace("\r\n", "\n"); + + // Determine the codeblock language tag to search for + let codeblock_lang = if server { + if language == "typescript" { + "ts server".to_string() + } else { + format!("{} server", language) + } + } else if language == "typescript" { + "ts".to_string() + } else { + language.to_string() + }; + + // Extract code blocks with the specified language + let pattern = format!(r"```{}\n([\s\S]*?)\n```", regex::escape(&codeblock_lang)); + let re = Regex::new(&pattern).unwrap(); + let mut blocks: Vec = re + .captures_iter(&doc_content) + .map(|cap| cap.get(1).unwrap().as_str().to_string()) + .collect(); + + let mut end = String::new(); + + // C# specific fixups + if language == "csharp" { + let mut found_on_connected = false; + let mut filtered_blocks = Vec::new(); + + for mut block in blocks { + // The doc first creates an empty class Module, so we need to fixup the closing brace + if block.contains("partial class Module") { + block = block.replace("}", ""); + end = "\n}".to_string(); + } + // Remove the first `OnConnected` block, which body is later updated + if block.contains("OnConnected(DbConnection conn") && !found_on_connected { + found_on_connected = true; + continue; + } + filtered_blocks.push(block); + } + blocks = filtered_blocks; + } + + // Join blocks and replace module name + let result = blocks.join("\n").replace("quickstart-chat", module_name); + result + &end +} + /// A smoketest instance that manages a SpacetimeDB server and module project. pub struct Smoketest { /// The SpacetimeDB server guard (stops server on drop). @@ -78,12 +201,38 @@ pub struct Smoketest { pub config_path: std::path::PathBuf, } +/// Response from an HTTP API call. +pub struct ApiResponse { + /// HTTP status code. + pub status_code: u16, + /// Response body. + pub body: Vec, +} + +impl ApiResponse { + /// Returns the body as a string. + pub fn text(&self) -> Result { + String::from_utf8(self.body.clone()).context("Response body is not valid UTF-8") + } + + /// Parses the body as JSON. + pub fn json(&self) -> Result { + serde_json::from_slice(&self.body).context("Failed to parse response as JSON") + } + + /// Returns true if the status code indicates success (2xx). + pub fn is_success(&self) -> bool { + (200..300).contains(&self.status_code) + } +} + /// Builder for creating `Smoketest` instances. pub struct SmoketestBuilder { module_code: Option, bindings_features: Vec, extra_deps: String, autopublish: bool, + pg_port: Option, } impl Default for SmoketestBuilder { @@ -100,9 +249,16 @@ impl SmoketestBuilder { bindings_features: vec!["unstable".to_string()], extra_deps: String::new(), autopublish: true, + pg_port: None, } } + /// Enables the PostgreSQL wire protocol on the specified port. + pub fn pg_port(mut self, port: u16) -> Self { + self.pg_port = Some(port); + self + } + /// Sets the module code to compile and publish. pub fn module_code(mut self, code: &str) -> Self { self.module_code = Some(code.to_string()); @@ -135,7 +291,10 @@ impl SmoketestBuilder { pub fn build(self) -> Smoketest { let build_start = Instant::now(); - let guard = timed!("server spawn", SpacetimeDbGuard::spawn_in_temp_data_dir()); + let guard = timed!( + "server spawn", + SpacetimeDbGuard::spawn_in_temp_data_dir_with_pg_port(self.pg_port) + ); let project_dir = tempfile::tempdir().expect("Failed to create temp project directory"); let project_setup_start = Instant::now(); @@ -212,6 +371,79 @@ impl Smoketest { SmoketestBuilder::new() } + /// Returns the server host (without protocol), e.g., "127.0.0.1:3000". + pub fn server_host(&self) -> &str { + self.server_url + .strip_prefix("http://") + .or_else(|| self.server_url.strip_prefix("https://")) + .unwrap_or(&self.server_url) + } + + /// Returns the PostgreSQL wire protocol port, if enabled. + pub fn pg_port(&self) -> Option { + self.guard.pg_port + } + + /// Reads the authentication token from the config file. + pub fn read_token(&self) -> Result { + let config_content = fs::read_to_string(&self.config_path) + .context("Failed to read config file")?; + + // Parse as TOML and extract spacetimedb_token + let config: toml::Value = config_content + .parse() + .context("Failed to parse config as TOML")?; + + config + .get("spacetimedb_token") + .and_then(|v| v.as_str()) + .map(String::from) + .context("No spacetimedb_token found in config") + } + + /// Runs psql command against the PostgreSQL wire protocol server. + /// + /// Returns the output on success, or an error with stderr on failure. + pub fn psql(&self, database: &str, sql: &str) -> Result { + let pg_port = self.pg_port().context("PostgreSQL wire protocol not enabled")?; + let token = self.read_token()?; + + // Extract just the host part (without port) + let host = self.server_host().split(':').next().unwrap_or("127.0.0.1"); + + let output = Command::new("psql") + .args([ + "-h", host, + "-p", &pg_port.to_string(), + "-U", "postgres", + "-d", database, + "--quiet", + "-c", sql, + ]) + .env("PGPASSWORD", &token) + .output() + .context("Failed to run psql")?; + + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.is_empty() && !output.status.success() { + bail!("{}", stderr.trim()); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + + /// Asserts that psql output matches the expected value. + pub fn assert_psql(&self, database: &str, sql: &str, expected: &str) { + let output = self.psql(database, sql).expect("psql failed"); + let output_normalized: String = output.lines().map(|l| l.trim_end()).collect::>().join("\n"); + let expected_normalized: String = expected.lines().map(|l| l.trim_end()).collect::>().join("\n"); + assert_eq!( + output_normalized, expected_normalized, + "psql output mismatch for query: {}\n\nExpected:\n{}\n\nActual:\n{}", + sql, expected_normalized, output_normalized + ); + } + /// Runs a spacetime CLI command with the configured server. /// /// Returns the command output. The command is run but not yet asserted. @@ -512,6 +744,75 @@ impl Smoketest { Ok(()) } + /// Makes an HTTP API call to the server. + /// + /// Returns the response body as bytes, or an error with the HTTP status code. + pub fn api_call(&self, method: &str, path: &str) -> Result { + self.api_call_with_body(method, path, None) + } + + /// Makes an HTTP API call with an optional request body. + pub fn api_call_with_body( + &self, + method: &str, + path: &str, + body: Option<&[u8]>, + ) -> Result { + use std::io::{Read, Write}; + use std::net::TcpStream; + + // Parse server URL to get host and port + let url = &self.server_url; + let host_port = url + .strip_prefix("http://") + .or_else(|| url.strip_prefix("https://")) + .unwrap_or(url); + + let mut stream = TcpStream::connect(host_port).context("Failed to connect to server")?; + stream + .set_read_timeout(Some(std::time::Duration::from_secs(30))) + .ok(); + + // Build HTTP request + let content_length = body.map(|b| b.len()).unwrap_or(0); + let request = format!( + "{} {} HTTP/1.1\r\nHost: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + method, path, host_port, content_length + ); + + stream.write_all(request.as_bytes())?; + if let Some(body) = body { + stream.write_all(body)?; + } + + // Read response + let mut response = Vec::new(); + stream.read_to_end(&mut response)?; + + // Parse HTTP response + let response_str = String::from_utf8_lossy(&response); + let mut lines = response_str.lines(); + + // Parse status line + let status_line = lines.next().context("Empty response")?; + let status_code: u16 = status_line + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .context("Failed to parse status code")?; + + // Find body (after empty line) + let header_end = response_str.find("\r\n\r\n").unwrap_or(response_str.len()); + let body_start = header_end + 4; + let body = if body_start < response.len() { + response[body_start..].to_vec() + } else { + Vec::new() + }; + + Ok(ApiResponse { status_code, body }) + } + /// Starts a subscription and waits for N updates (synchronous). /// /// Returns the updates as JSON values. diff --git a/crates/smoketests/tests/quickstart.rs b/crates/smoketests/tests/quickstart.rs new file mode 100644 index 00000000000..0d8ca3725fd --- /dev/null +++ b/crates/smoketests/tests/quickstart.rs @@ -0,0 +1,697 @@ +//! Tests translated from smoketests/tests/quickstart.py +//! +//! This test validates that the quickstart documentation is correct by extracting +//! code from markdown docs and running it. + +use anyhow::{bail, Context, Result}; +use spacetimedb_smoketests::{have_dotnet, have_pnpm, parse_quickstart, workspace_root, Smoketest}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// Write content to a file, creating parent directories as needed. +fn write_file(path: &Path, content: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, content)?; + Ok(()) +} + +/// Append content to a file. +fn append_to_file(path: &Path, content: &str) -> Result<()> { + use std::io::Write; + let mut file = fs::OpenOptions::new().append(true).open(path)?; + file.write_all(content.as_bytes())?; + Ok(()) +} + +/// Run a command and return stdout as a string. +fn run_cmd(args: &[&str], cwd: &Path, input: Option<&str>) -> Result { + let mut cmd = Command::new(args[0]); + cmd.args(&args[1..]) + .current_dir(cwd) + .stderr(Stdio::piped()) + .stdout(Stdio::piped()); + + if input.is_some() { + cmd.stdin(Stdio::piped()); + } + + let mut child = cmd.spawn().context(format!("Failed to spawn {:?}", args))?; + + if let Some(input_str) = input { + use std::io::Write; + if let Some(stdin) = child.stdin.as_mut() { + stdin.write_all(input_str.as_bytes())?; + } + } + + let output = child.wait_with_output()?; + + if !output.status.success() { + bail!( + "Command {:?} failed:\nstdout: {}\nstderr: {}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +/// Run pnpm command. +fn pnpm(args: &[&str], cwd: &Path) -> Result { + let mut full_args = vec!["pnpm"]; + full_args.extend(args); + run_cmd(&full_args, cwd, None) +} + +/// Build the TypeScript SDK. +fn build_typescript_sdk() -> Result<()> { + let workspace = workspace_root(); + let ts_bindings = workspace.join("crates/bindings-typescript"); + pnpm(&["install"], &ts_bindings)?; + pnpm(&["build"], &ts_bindings)?; + Ok(()) +} + +/// Load NuGet config from a file, returning a simple representation. +/// We'll use a string-based approach for simplicity since we don't have xmltodict. +fn create_nuget_config(sources: &[(String, PathBuf)], mappings: &[(String, String)]) -> String { + let mut source_lines = String::new(); + let mut mapping_lines = String::new(); + + for (key, path) in sources { + source_lines.push_str(&format!( + " \n", + key, + path.display() + )); + } + + for (key, pattern) in mappings { + mapping_lines.push_str(&format!( + " \n \n \n", + key, pattern + )); + } + + format!( + r#" + + +{} + +{} + +"#, + source_lines, mapping_lines + ) +} + +/// Override nuget config to use a local NuGet package on a .NET project. +fn override_nuget_package(project_dir: &Path, package: &str, source_dir: &Path, build_subdir: &str) -> Result<()> { + // Make sure the local package is built + let output = Command::new("dotnet") + .args(["pack"]) + .current_dir(source_dir) + .output() + .context("Failed to run dotnet pack")?; + + if !output.status.success() { + bail!( + "dotnet pack failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + let nuget_config_path = project_dir.join("nuget.config"); + let package_path = source_dir.join(build_subdir); + + // Read existing config or create new one + let (mut sources, mut mappings) = if nuget_config_path.exists() { + // Parse existing config - simplified approach + let content = fs::read_to_string(&nuget_config_path)?; + parse_nuget_config(&content) + } else { + (Vec::new(), Vec::new()) + }; + + // Add new source + sources.push((package.to_string(), package_path)); + + // Add mapping for the package + mappings.push((package.to_string(), package.to_string())); + + // Ensure nuget.org fallback exists + if !mappings.iter().any(|(k, _)| k == "nuget.org") { + mappings.push(("nuget.org".to_string(), "*".to_string())); + } + + // Write config + let config = create_nuget_config(&sources, &mappings); + fs::write(&nuget_config_path, config)?; + + // Clear nuget caches + let _ = Command::new("dotnet") + .args(["nuget", "locals", "--clear", "all"]) + .stderr(Stdio::null()) + .output(); + + Ok(()) +} + +/// Parse an existing nuget.config file (simplified). +fn parse_nuget_config(content: &str) -> (Vec<(String, PathBuf)>, Vec<(String, String)>) { + let mut sources = Vec::new(); + let mut mappings = Vec::new(); + + // Simple regex-based parsing + let source_re = regex::Regex::new(r#"\s* Self { + Self { + lang: "rust", + client_lang: "rust", + server_file: "src/lib.rs", + client_file: "src/main.rs", + module_bindings: "src/module_bindings", + run_cmd: &["cargo", "run"], + build_cmd: &["cargo", "build"], + replacements: &[ + // Replace the interactive user input to allow direct testing + ("user_input_loop(&ctx)", "user_input_direct(&ctx)"), + // Don't cache the token, because it will cause the test to fail if we run against a non-default server + ( + ".with_token(creds_store()", + "//.with_token(creds_store()", + ), + ], + extra_code: r#" +fn user_input_direct(ctx: &DbConnection) { + let mut line = String::new(); + std::io::stdin().read_line(&mut line).expect("Failed to read from stdin."); + if let Some(name) = line.strip_prefix("/name ") { + ctx.reducers.set_name(name.to_string()).unwrap(); + } else { + ctx.reducers.send_message(line).unwrap(); + } + std::thread::sleep(std::time::Duration::from_secs(1)); + std::process::exit(0); +} +"#, + connected_str: "connected", + } + } + + fn csharp() -> Self { + Self { + lang: "csharp", + client_lang: "csharp", + server_file: "Lib.cs", + client_file: "Program.cs", + module_bindings: "module_bindings", + run_cmd: &["dotnet", "run"], + build_cmd: &["dotnet", "build"], + replacements: &[ + // Replace the interactive user input to allow direct testing + ("InputLoop();", "UserInputDirect();"), + (".OnConnect(OnConnected)", ".OnConnect(OnConnectedSignal)"), + ( + ".OnConnectError(OnConnectError)", + ".OnConnectError(OnConnectErrorSignal)", + ), + // Don't cache the token + (".WithToken(AuthToken.Token)", "//.WithToken(AuthToken.Token)"), + // To put the main function at the end so it can see the new functions + ("Main();", ""), + ], + extra_code: r#" +var connectedEvent = new ManualResetEventSlim(false); +var connectionFailed = new ManualResetEventSlim(false); +void OnConnectErrorSignal(Exception e) +{ + OnConnectError(e); + connectionFailed.Set(); +} +void OnConnectedSignal(DbConnection conn, Identity identity, string authToken) +{ + OnConnected(conn, identity, authToken); + connectedEvent.Set(); +} + +void UserInputDirect() { + string? line = Console.In.ReadToEnd()?.Trim(); + if (line == null) Environment.Exit(0); + + if (!WaitHandle.WaitAny( + new[] { connectedEvent.WaitHandle, connectionFailed.WaitHandle }, + TimeSpan.FromSeconds(5) + ).Equals(0)) + { + Console.WriteLine("Failed to connect to server within timeout."); + Environment.Exit(1); + } + + if (line.StartsWith("/name ")) { + input_queue.Enqueue(("name", line[6..])); + } else { + input_queue.Enqueue(("message", line)); + } + Thread.Sleep(1000); +} +Main(); +"#, + connected_str: "Connected", + } + } + + fn typescript() -> Self { + // TypeScript server uses Rust client because the TypeScript client + // quickstart is a React app, which is difficult to smoketest. + Self { + lang: "typescript", + client_lang: "rust", + server_file: "src/index.ts", + // Client uses Rust config + client_file: "src/main.rs", + module_bindings: "src/module_bindings", + run_cmd: &["cargo", "run"], + build_cmd: &["cargo", "build"], + replacements: &[ + ("user_input_loop(&ctx)", "user_input_direct(&ctx)"), + ( + ".with_token(creds_store()", + "//.with_token(creds_store()", + ), + ], + extra_code: r#" +fn user_input_direct(ctx: &DbConnection) { + let mut line = String::new(); + std::io::stdin().read_line(&mut line).expect("Failed to read from stdin."); + if let Some(name) = line.strip_prefix("/name ") { + ctx.reducers.set_name(name.to_string()).unwrap(); + } else { + ctx.reducers.send_message(line).unwrap(); + } + std::thread::sleep(std::time::Duration::from_secs(1)); + std::process::exit(0); +} +"#, + connected_str: "connected", + } + } +} + +/// Quickstart test runner. +struct QuickstartTest { + test: Smoketest, + config: QuickstartConfig, + project_path: PathBuf, + /// Temp directory for server/client - kept alive for duration of test + _temp_dir: Option, +} + +impl QuickstartTest { + fn new(config: QuickstartConfig) -> Self { + let test = Smoketest::builder().autopublish(false).build(); + Self { + test, + config, + project_path: PathBuf::new(), + _temp_dir: None, + } + } + + fn module_name(&self) -> String { + format!("quickstart-chat-{}", self.config.lang) + } + + fn doc_path(&self) -> PathBuf { + workspace_root().join("docs/docs/00100-intro/00300-tutorials/00100-chat-app.md") + } + + /// Generate the server code from the quickstart documentation. + fn generate_server(&mut self, server_path: &Path) -> Result { + let workspace = workspace_root(); + eprintln!("Generating server code {}: {:?}...", self.config.lang, server_path); + + // Initialize the project (local operation, doesn't need server) + let output = self.test.spacetime_local(&[ + "init", + "--non-interactive", + "--lang", + self.config.lang, + "--project-path", + server_path.to_str().unwrap(), + "spacetimedb-project", + ])?; + eprintln!("spacetime init output: {}", output); + + let project_path = server_path.join("spacetimedb"); + self.project_path = project_path.clone(); + + // Copy rust-toolchain.toml + let toolchain_src = workspace.join("rust-toolchain.toml"); + if toolchain_src.exists() { + fs::copy(&toolchain_src, project_path.join("rust-toolchain.toml"))?; + } + + // Read and parse the documentation + let doc_content = fs::read_to_string(self.doc_path())?; + let server_code = parse_quickstart(&doc_content, self.config.lang, &self.module_name(), true); + + // Write server code + write_file(&project_path.join(self.config.server_file), &server_code)?; + + // Language-specific server postprocessing + self.server_postprocess(&project_path)?; + + // Build the server (local operation) + self.test.spacetime_local(&[ + "build", + "-d", + "-p", + project_path.to_str().unwrap(), + ])?; + + Ok(project_path) + } + + /// Language-specific server postprocessing. + fn server_postprocess(&self, server_path: &Path) -> Result<()> { + let workspace = workspace_root(); + + match self.config.lang { + "rust" => { + // Write the Cargo.toml with local bindings path + let bindings_path = workspace.join("crates/bindings"); + let bindings_path_str = bindings_path.display().to_string().replace('\\', "/"); + + let cargo_toml = format!( + r#"[package] +name = "spacetimedb-quickstart-module" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb = {{ path = "{}", features = ["unstable"] }} +log = "0.4" +"#, + bindings_path_str + ); + fs::write(server_path.join("Cargo.toml"), cargo_toml)?; + } + "csharp" => { + // Set up local NuGet packages + override_nuget_package( + server_path, + "SpacetimeDB.Runtime", + &workspace.join("crates/bindings-csharp/Runtime"), + "bin/Release", + )?; + override_nuget_package( + server_path, + "SpacetimeDB.BSATN.Runtime", + &workspace.join("crates/bindings-csharp/BSATN.Runtime"), + "bin/Release", + )?; + } + "typescript" => { + // Build and link the TypeScript SDK + build_typescript_sdk()?; + + // Uninstall spacetimedb first to avoid pnpm issues + let _ = pnpm(&["uninstall", "spacetimedb"], server_path); + + // Install the local SDK + let ts_bindings = workspace.join("crates/bindings-typescript"); + pnpm( + &["install", ts_bindings.to_str().unwrap()], + server_path, + )?; + } + _ => {} + } + + Ok(()) + } + + /// Initialize the client project. + fn project_init(&self, client_path: &Path) -> Result<()> { + match self.config.client_lang { + "rust" => { + let parent = client_path.parent().unwrap(); + run_cmd( + &[ + "cargo", + "new", + "--bin", + "--name", + "quickstart_chat_client", + "client", + ], + parent, + None, + )?; + } + "csharp" => { + run_cmd( + &[ + "dotnet", + "new", + "console", + "--name", + "QuickstartChatClient", + "--output", + client_path.to_str().unwrap(), + ], + client_path.parent().unwrap(), + None, + )?; + } + _ => {} + } + Ok(()) + } + + /// Set up the SDK for the client. + fn sdk_setup(&self, client_path: &Path) -> Result<()> { + let workspace = workspace_root(); + + match self.config.client_lang { + "rust" => { + let sdk_rust_path = workspace.join("sdks/rust"); + let sdk_rust_toml_escaped = sdk_rust_path + .display() + .to_string() + .replace('\\', "\\\\\\\\"); // double escape for toml + let sdk_rust_toml = format!( + "spacetimedb-sdk = {{ path = \"{}\" }}\nlog = \"0.4\"\nhex = \"0.4\"\n", + sdk_rust_toml_escaped + ); + append_to_file(&client_path.join("Cargo.toml"), &sdk_rust_toml)?; + } + "csharp" => { + // Set up NuGet packages for C# SDK + override_nuget_package( + &workspace.join("sdks/csharp"), + "SpacetimeDB.BSATN.Runtime", + &workspace.join("crates/bindings-csharp/BSATN.Runtime"), + "bin/Release", + )?; + override_nuget_package( + &workspace.join("sdks/csharp"), + "SpacetimeDB.Runtime", + &workspace.join("crates/bindings-csharp/Runtime"), + "bin/Release", + )?; + override_nuget_package( + client_path, + "SpacetimeDB.BSATN.Runtime", + &workspace.join("crates/bindings-csharp/BSATN.Runtime"), + "bin/Release", + )?; + override_nuget_package( + client_path, + "SpacetimeDB.ClientSDK", + &workspace.join("sdks/csharp"), + "bin~/Release", + )?; + + run_cmd( + &["dotnet", "add", "package", "SpacetimeDB.ClientSDK"], + client_path, + None, + )?; + } + _ => {} + } + Ok(()) + } + + /// Run the client with input and check output. + fn check(&self, input: &str, client_path: &Path, contains: &str) -> Result<()> { + let output = run_cmd(self.config.run_cmd, client_path, Some(input))?; + eprintln!("Output for {} client:\n{}", self.config.lang, output); + + if !output.contains(contains) { + bail!( + "Expected output to contain '{}', but got:\n{}", + contains, + output + ); + } + Ok(()) + } + + /// Publish the module and return the client path. + fn publish(&mut self) -> Result { + let temp_dir = tempfile::tempdir()?; + let base_path = temp_dir.path().to_path_buf(); + self._temp_dir = Some(temp_dir); + let server_path = base_path.join("server"); + + self.generate_server(&server_path)?; + + // Publish the module + let project_path_str = self.project_path.to_str().unwrap().to_string(); + let publish_output = self.test.spacetime(&[ + "publish", + "--project-path", + &project_path_str, + "--yes", + "--clear-database", + &self.module_name(), + ])?; + + // Parse the identity from publish output + let re = regex::Regex::new(r"identity: ([0-9a-fA-F]+)").unwrap(); + if let Some(caps) = re.captures(&publish_output) { + let identity = caps.get(1).unwrap().as_str().to_string(); + self.test.database_identity = Some(identity); + } else { + bail!("Failed to parse database identity from publish output: {}", publish_output); + } + + Ok(base_path.join("client")) + } + + /// Run the full quickstart test. + fn run_quickstart(&mut self) -> Result<()> { + let client_path = self.publish()?; + + self.project_init(&client_path)?; + self.sdk_setup(&client_path)?; + + // Build the client + run_cmd(self.config.build_cmd, &client_path, None)?; + + // Generate bindings (local operation) + let bindings_path = client_path.join(self.config.module_bindings); + let project_path_str = self.project_path.to_str().unwrap().to_string(); + self.test.spacetime_local(&[ + "generate", + "--lang", + self.config.client_lang, + "--out-dir", + bindings_path.to_str().unwrap(), + "--project-path", + &project_path_str, + ])?; + + // Read and parse client code from documentation + let doc_content = fs::read_to_string(self.doc_path())?; + let mut main_code = + parse_quickstart(&doc_content, self.config.client_lang, &self.module_name(), false); + + // Apply replacements + for (src, dst) in self.config.replacements { + main_code = main_code.replace(src, dst); + } + + // Add extra code + main_code.push_str("\n"); + main_code.push_str(self.config.extra_code); + + // Replace server address + let host = self.test.server_host(); + let protocol = "http"; // The smoketest server uses http + main_code = main_code.replace("http://localhost:3000", &format!("{}://{}", protocol, host)); + + // Write the client code + write_file(&client_path.join(self.config.client_file), &main_code)?; + + // Run the three test interactions + self.check("", &client_path, self.config.connected_str)?; + self.check("/name Alice", &client_path, "Alice")?; + self.check("Hello World", &client_path, "Hello World")?; + + Ok(()) + } +} + +/// Run the Rust quickstart guides for server and client. +#[test] +fn test_quickstart_rust() { + let mut qt = QuickstartTest::new(QuickstartConfig::rust()); + qt.run_quickstart().expect("Rust quickstart test failed"); +} + +/// Run the C# quickstart guides for server and client. +#[test] +fn test_quickstart_csharp() { + if !have_dotnet() { + eprintln!("Skipping test_quickstart_csharp: dotnet 8.0+ not available"); + return; + } + + let mut qt = QuickstartTest::new(QuickstartConfig::csharp()); + qt.run_quickstart().expect("C# quickstart test failed"); +} + +/// Run the TypeScript quickstart for server (with Rust client). +#[test] +fn test_quickstart_typescript() { + if !have_pnpm() { + eprintln!("Skipping test_quickstart_typescript: pnpm not available"); + return; + } + + let mut qt = QuickstartTest::new(QuickstartConfig::typescript()); + qt.run_quickstart() + .expect("TypeScript quickstart test failed"); +} From 03bbd19a99c5624a611cbd974d9868fa7a160b0d Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 11:08:23 -0800 Subject: [PATCH 025/118] [tyler/translate-smoketests]: actually fix windows ci --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f8e69df655..31768e0a86e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,7 @@ jobs: # `cargo build --manifest-path` (which apparently build different dependency trees). # However, we've been unable to fix it so... /shrug - name: Check v8 outputs + shell: bash run: | find "${CARGO_TARGET_DIR}"/ -type f | grep '[/_]v8' || true if ! [ -f "${CARGO_TARGET_DIR}"/debug/gn_out/obj/librusty_v8.a ]; then @@ -719,7 +720,6 @@ jobs: # `cargo build --manifest-path` (which apparently build different dependency trees). # However, we've been unable to fix it so... /shrug - name: Check v8 outputs - shell: bash run: | find "${CARGO_TARGET_DIR}"/ -type f | grep '[/_]v8' || true if ! [ -f "${CARGO_TARGET_DIR}"/debug/gn_out/obj/librusty_v8.a ]; then From 157a81434d2394d03acc0250b76f32312da4ad55 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 23 Jan 2026 14:09:23 -0500 Subject: [PATCH 026/118] cargo fmt --all --- crates/guard/src/lib.rs | 27 +- crates/smoketests/src/lib.rs | 44 ++-- crates/smoketests/tests/csharp_module.rs | 126 +++++++++ crates/smoketests/tests/pg_wire.rs | 289 +++++++++++++++++++++ crates/smoketests/tests/quickstart.rs | 63 ++--- crates/smoketests/tests/timestamp_route.rs | 55 ++++ 6 files changed, 527 insertions(+), 77 deletions(-) create mode 100644 crates/smoketests/tests/csharp_module.rs create mode 100644 crates/smoketests/tests/pg_wire.rs create mode 100644 crates/smoketests/tests/timestamp_route.rs diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 82b7181ee29..0f4fefcad54 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -83,6 +83,8 @@ pub struct SpacetimeDbGuard { pub child: Child, pub host_url: String, pub logs: Arc>, + /// The PostgreSQL wire protocol port, if enabled. + pub pg_port: Option, } // Remove all Cargo-provided env vars from a child process. These are set by the fact that we're running in a cargo @@ -92,10 +94,15 @@ impl SpacetimeDbGuard { /// Start `spacetimedb` in a temporary data directory via: /// cargo run -p spacetimedb-cli -- start --data-dir --listen-addr pub fn spawn_in_temp_data_dir() -> Self { + Self::spawn_in_temp_data_dir_with_pg_port(None) + } + + /// Start `spacetimedb` in a temporary data directory with optional PostgreSQL wire protocol. + pub fn spawn_in_temp_data_dir_with_pg_port(pg_port: Option) -> Self { let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); let data_dir = temp_dir.path().display().to_string(); - Self::spawn_spacetime_start(false, &["start", "--data-dir", &data_dir]) + Self::spawn_spacetime_start(false, &["start", "--data-dir", &data_dir], pg_port) } /// Start `spacetimedb` in a temporary data directory via: @@ -104,19 +111,23 @@ impl SpacetimeDbGuard { let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); let data_dir = temp_dir.path().display().to_string(); - Self::spawn_spacetime_start(true, &["start", "--data-dir", &data_dir]) + Self::spawn_spacetime_start(true, &["start", "--data-dir", &data_dir], None) } - fn spawn_spacetime_start(use_installed_cli: bool, extra_args: &[&str]) -> Self { + fn spawn_spacetime_start(use_installed_cli: bool, extra_args: &[&str], pg_port: Option) -> Self { // Ask SpacetimeDB/OS to allocate an ephemeral port. // Using loopback avoids needing to "connect to 0.0.0.0". let address = "127.0.0.1:0".to_string(); + let pg_port_str = pg_port.map(|p| p.to_string()); let mut args = vec![]; let (child, logs) = if use_installed_cli { args.extend_from_slice(extra_args); args.extend_from_slice(&["--listen-addr", &address]); + if let Some(ref port) = pg_port_str { + args.extend_from_slice(&["--pg-port", port]); + } let cmd = Command::new("spacetime"); Self::spawn_child(cmd, env!("CARGO_MANIFEST_DIR"), &args) @@ -125,6 +136,9 @@ impl SpacetimeDbGuard { args.extend(extra_args); args.extend(["--listen-addr", &address]); + if let Some(ref port) = pg_port_str { + args.extend(["--pg-port", port]); + } let cmd = Command::new(&cli_path); @@ -141,7 +155,12 @@ impl SpacetimeDbGuard { let listen_addr = wait_for_listen_addr(&logs, Duration::from_secs(10)) .unwrap_or_else(|| panic!("Timed out waiting for SpacetimeDB to report listen address")); let host_url = format!("http://{}", listen_addr); - let guard = SpacetimeDbGuard { child, host_url, logs }; + let guard = SpacetimeDbGuard { + child, + host_url, + logs, + pg_port, + }; guard.wait_until_http_ready(Duration::from_secs(10)); guard } diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 20282ec26ea..6cd86f1ea0a 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -68,10 +68,7 @@ pub fn workspace_root() -> PathBuf { /// Generates a random lowercase alphabetic string suitable for database names. pub fn random_string() -> String { use std::time::{SystemTime, UNIX_EPOCH}; - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); // Convert to base-26 using lowercase letters only (a-z) let mut result = String::with_capacity(20); let mut n = timestamp; @@ -96,9 +93,9 @@ pub fn have_dotnet() -> bool { } let stdout = String::from_utf8_lossy(&output.stdout); // Check for dotnet 8.0 or higher - stdout.lines().any(|line| { - line.starts_with("8.") || line.starts_with("9.") || line.starts_with("10.") - }) + stdout + .lines() + .any(|line| line.starts_with("8.") || line.starts_with("9.") || line.starts_with("10.")) }) .unwrap_or(false) }) @@ -386,13 +383,10 @@ impl Smoketest { /// Reads the authentication token from the config file. pub fn read_token(&self) -> Result { - let config_content = fs::read_to_string(&self.config_path) - .context("Failed to read config file")?; + let config_content = fs::read_to_string(&self.config_path).context("Failed to read config file")?; // Parse as TOML and extract spacetimedb_token - let config: toml::Value = config_content - .parse() - .context("Failed to parse config as TOML")?; + let config: toml::Value = config_content.parse().context("Failed to parse config as TOML")?; config .get("spacetimedb_token") @@ -413,12 +407,17 @@ impl Smoketest { let output = Command::new("psql") .args([ - "-h", host, - "-p", &pg_port.to_string(), - "-U", "postgres", - "-d", database, + "-h", + host, + "-p", + &pg_port.to_string(), + "-U", + "postgres", + "-d", + database, "--quiet", - "-c", sql, + "-c", + sql, ]) .env("PGPASSWORD", &token) .output() @@ -752,12 +751,7 @@ impl Smoketest { } /// Makes an HTTP API call with an optional request body. - pub fn api_call_with_body( - &self, - method: &str, - path: &str, - body: Option<&[u8]>, - ) -> Result { + pub fn api_call_with_body(&self, method: &str, path: &str, body: Option<&[u8]>) -> Result { use std::io::{Read, Write}; use std::net::TcpStream; @@ -769,9 +763,7 @@ impl Smoketest { .unwrap_or(url); let mut stream = TcpStream::connect(host_port).context("Failed to connect to server")?; - stream - .set_read_timeout(Some(std::time::Duration::from_secs(30))) - .ok(); + stream.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok(); // Build HTTP request let content_length = body.map(|b| b.len()).unwrap_or(0); diff --git a/crates/smoketests/tests/csharp_module.rs b/crates/smoketests/tests/csharp_module.rs new file mode 100644 index 00000000000..148a4bcb47f --- /dev/null +++ b/crates/smoketests/tests/csharp_module.rs @@ -0,0 +1,126 @@ +//! Tests translated from smoketests/tests/csharp_module.py + +use spacetimedb_smoketests::{have_dotnet, workspace_root}; +use std::fs; +use std::process::Command; + +/// Ensure that the CLI is able to create and compile a C# project. +/// This test does not depend on a running SpacetimeDB instance. +/// Skips if dotnet 8.0+ is not available. +#[test] +fn test_build_csharp_module() { + if !have_dotnet() { + eprintln!("Skipping test_build_csharp_module: dotnet 8.0+ not available"); + return; + } + + let workspace = workspace_root(); + let bindings = workspace.join("crates/bindings-csharp"); + let cli_path = workspace.join("target/debug/spacetimedb-cli"); + + // Build the CLI if needed + let status = Command::new("cargo") + .args(["build", "-p", "spacetimedb-cli"]) + .current_dir(&workspace) + .status() + .expect("Failed to build CLI"); + assert!(status.success(), "Failed to build spacetimedb-cli"); + + // Clear nuget locals + let status = Command::new("dotnet") + .args(["nuget", "locals", "all", "--clear"]) + .current_dir(&bindings) + .status() + .expect("Failed to clear nuget locals"); + assert!(status.success(), "Failed to clear nuget locals"); + + // Install wasi-experimental workload + let _status = Command::new("dotnet") + .args(["workload", "install", "wasi-experimental", "--skip-manifest-update"]) + .current_dir(workspace.join("modules")) + .status() + .expect("Failed to install wasi workload"); + // This may fail if already installed, so we don't assert success + + // Pack the bindings + let status = Command::new("dotnet") + .args(["pack"]) + .current_dir(&bindings) + .status() + .expect("Failed to pack bindings"); + assert!(status.success(), "Failed to pack C# bindings"); + + // Create temp directory for the project + let tmpdir = tempfile::tempdir().expect("Failed to create temp directory"); + + // Initialize C# project + let output = Command::new(&cli_path) + .args([ + "init", + "--non-interactive", + "--lang=csharp", + "--project-path", + tmpdir.path().to_str().unwrap(), + "csharp-project", + ]) + .output() + .expect("Failed to run spacetime init"); + assert!( + output.status.success(), + "spacetime init failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let server_path = tmpdir.path().join("spacetimedb"); + + // Create nuget.config with local package sources + let packed_projects = ["BSATN.Runtime", "Runtime"]; + let mut sources = String::new(); + let mut mappings = String::new(); + + for project in &packed_projects { + let path = bindings.join(project).join("bin/Release"); + let package_name = format!("SpacetimeDB.{}", project); + sources.push_str(&format!( + " \n", + package_name, + path.display() + )); + mappings.push_str(&format!( + " \n \n \n", + package_name, package_name + )); + } + // Add fallback for other packages + mappings.push_str(" \n \n \n"); + + let nuget_config = format!( + r#" + + +{} + +{} + +"#, + sources, mappings + ); + + eprintln!("Writing nuget.config contents:\n{}", nuget_config); + fs::write(server_path.join("nuget.config"), &nuget_config).expect("Failed to write nuget.config"); + + // Run dotnet publish + let output = Command::new("dotnet") + .args(["publish"]) + .current_dir(&server_path) + .output() + .expect("Failed to run dotnet publish"); + + assert!( + output.status.success(), + "dotnet publish failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/smoketests/tests/pg_wire.rs b/crates/smoketests/tests/pg_wire.rs new file mode 100644 index 00000000000..e1b9efeb169 --- /dev/null +++ b/crates/smoketests/tests/pg_wire.rs @@ -0,0 +1,289 @@ +//! Tests translated from smoketests/tests/pg_wire.py + +use spacetimedb_smoketests::{have_psql, Smoketest}; + +const MODULE_CODE: &str = r#" +use spacetimedb::sats::{i256, u256}; +use spacetimedb::{ConnectionId, Identity, ReducerContext, SpacetimeType, Table, Timestamp, TimeDuration, Uuid}; + +#[derive(Copy, Clone)] +#[spacetimedb::table(name = t_ints, public)] +pub struct TInts { + i8: i8, + i16: i16, + i32: i32, + i64: i64, + i128: i128, + i256: i256, +} + +#[spacetimedb::table(name = t_ints_tuple, public)] +pub struct TIntsTuple { + tuple: TInts, +} + +#[derive(Copy, Clone)] +#[spacetimedb::table(name = t_uints, public)] +pub struct TUints { + u8: u8, + u16: u16, + u32: u32, + u64: u64, + u128: u128, + u256: u256, +} + +#[spacetimedb::table(name = t_uints_tuple, public)] +pub struct TUintsTuple { + tuple: TUints, +} + +#[derive(Clone)] +#[spacetimedb::table(name = t_others, public)] +pub struct TOthers { + bool: bool, + f32: f32, + f64: f64, + str: String, + bytes: Vec, + identity: Identity, + connection_id: ConnectionId, + timestamp: Timestamp, + duration: TimeDuration, + uuid: Uuid, +} + +#[spacetimedb::table(name = t_others_tuple, public)] +pub struct TOthersTuple { + tuple: TOthers +} + +#[derive(SpacetimeType, Debug, Clone, Copy)] +pub enum Action { + Inactive, + Active, +} + +#[derive(SpacetimeType, Debug, Clone, Copy)] +pub enum Color { + Gray(u8), +} + +#[derive(Copy, Clone)] +#[spacetimedb::table(name = t_simple_enum, public)] +pub struct TSimpleEnum { + id: u32, + action: Action, +} + +#[spacetimedb::table(name = t_enum, public)] +pub struct TEnum { + id: u32, + color: Color, +} + +#[spacetimedb::table(name = t_nested, public)] +pub struct TNested { + en: TEnum, + se: TSimpleEnum, + ints: TInts, +} + +#[derive(Clone)] +#[spacetimedb::table(name = t_enums)] +pub struct TEnums { + bool_opt: Option, + bool_result: Result, + action: Action, +} + +#[spacetimedb::table(name = t_enums_tuple)] +pub struct TEnumsTuple { + tuple: TEnums, +} + +#[spacetimedb::reducer] +pub fn test(ctx: &ReducerContext) { + let tuple = TInts { + i8: -25, + i16: -3224, + i32: -23443, + i64: -2344353, + i128: -234434897853, + i256: (-234434897853i128).into(), + }; + let ints = tuple; + ctx.db.t_ints().insert(tuple); + ctx.db.t_ints_tuple().insert(TIntsTuple { tuple }); + + let tuple = TUints { + u8: 105, + u16: 1050, + u32: 83892, + u64: 48937498, + u128: 4378528978889, + u256: 4378528978889u128.into(), + }; + ctx.db.t_uints().insert(tuple); + ctx.db.t_uints_tuple().insert(TUintsTuple { tuple }); + + let tuple = TOthers { + bool: true, + f32: 594806.58906, + f64: -3454353.345389043278459, + str: "This is spacetimedb".to_string(), + bytes: vec!(1, 2, 3, 4, 5, 6, 7), + identity: Identity::ONE, + connection_id: ConnectionId::ZERO, + timestamp: Timestamp::UNIX_EPOCH, + duration: TimeDuration::from_micros(1000 * 10000), + uuid: Uuid::NIL, + }; + ctx.db.t_others().insert(tuple.clone()); + ctx.db.t_others_tuple().insert(TOthersTuple { tuple }); + + ctx.db.t_simple_enum().insert(TSimpleEnum { id: 1, action: Action::Inactive }); + ctx.db.t_simple_enum().insert(TSimpleEnum { id: 2, action: Action::Active }); + + ctx.db.t_enum().insert(TEnum { id: 1, color: Color::Gray(128) }); + + ctx.db.t_nested().insert(TNested { + en: TEnum { id: 1, color: Color::Gray(128) }, + se: TSimpleEnum { id: 2, action: Action::Active }, + ints, + }); + + let tuple = TEnums { + bool_opt: Some(true), + bool_result: Ok(false), + action: Action::Active, + }; + + ctx.db.t_enums().insert(tuple.clone()); + ctx.db.t_enums_tuple().insert(TEnumsTuple { tuple }); +} +"#; + +/// Test SQL output formatting via psql +#[test] +fn test_sql_format() { + if !have_psql() { + eprintln!("Skipping test_sql_format: psql not available"); + return; + } + + let mut test = Smoketest::builder() + .module_code(MODULE_CODE) + .pg_port(5433) // Use non-standard port to avoid conflicts + .autopublish(false) + .build(); + + test.publish_module_named("quickstart", true).unwrap(); + test.call("test", &[]).unwrap(); + + test.assert_psql( + "quickstart", + "SELECT * FROM t_ints", + r#"i8 | i16 | i32 | i64 | i128 | i256 +-----+-------+--------+----------+---------------+--------------- + -25 | -3224 | -23443 | -2344353 | -234434897853 | -234434897853 +(1 row)"#, + ); + + test.assert_psql( + "quickstart", + "SELECT * FROM t_ints_tuple", + r#"tuple +--------------------------------------------------------------------------------------------------------- + {"i8": -25, "i16": -3224, "i32": -23443, "i64": -2344353, "i128": -234434897853, "i256": -234434897853} +(1 row)"#, + ); + + test.assert_psql( + "quickstart", + "SELECT * FROM t_uints", + r#"u8 | u16 | u32 | u64 | u128 | u256 +-----+------+-------+----------+---------------+--------------- + 105 | 1050 | 83892 | 48937498 | 4378528978889 | 4378528978889 +(1 row)"#, + ); + + test.assert_psql( + "quickstart", + "SELECT * FROM t_uints_tuple", + r#"tuple +------------------------------------------------------------------------------------------------------- + {"u8": 105, "u16": 1050, "u32": 83892, "u64": 48937498, "u128": 4378528978889, "u256": 4378528978889} +(1 row)"#, + ); + + test.assert_psql( + "quickstart", + "SELECT * FROM t_simple_enum", + r#"id | action +----+---------- + 1 | Inactive + 2 | Active +(2 rows)"#, + ); + + test.assert_psql( + "quickstart", + "SELECT * FROM t_enum", + r#"id | color +----+--------------- + 1 | {"Gray": 128} +(1 row)"#, + ); +} + +/// Test failure cases +#[test] +fn test_failures() { + if !have_psql() { + eprintln!("Skipping test_failures: psql not available"); + return; + } + + let mut test = Smoketest::builder() + .module_code(MODULE_CODE) + .pg_port(5434) // Use different port from test_sql_format + .autopublish(false) + .build(); + + test.publish_module_named("quickstart", true).unwrap(); + + // Empty query returns empty result + let output = test.psql("quickstart", "").unwrap(); + assert!( + output.is_empty(), + "Expected empty output for empty query, got: {}", + output + ); + + // Connection fails with invalid token - we can't easily test this without + // modifying the token, so skip this part + + // Returns error for unsupported sql statements + let result = test.psql( + "quickstart", + "SELECT CASE a WHEN 1 THEN 'one' ELSE 'other' END FROM t_uints", + ); + assert!(result.is_err(), "Expected error for unsupported SQL"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("Unsupported") || err.contains("unsupported"), + "Expected 'Unsupported' in error message, got: {}", + err + ); + + // And prepared statements + let result = test.psql("quickstart", "SELECT * FROM t_uints where u8 = $1"); + assert!(result.is_err(), "Expected error for prepared statement"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("Unsupported") || err.contains("unsupported"), + "Expected 'Unsupported' in error message, got: {}", + err + ); +} diff --git a/crates/smoketests/tests/quickstart.rs b/crates/smoketests/tests/quickstart.rs index 0d8ca3725fd..48b08cf2e0e 100644 --- a/crates/smoketests/tests/quickstart.rs +++ b/crates/smoketests/tests/quickstart.rs @@ -84,11 +84,7 @@ fn create_nuget_config(sources: &[(String, PathBuf)], mappings: &[(String, Strin let mut mapping_lines = String::new(); for (key, path) in sources { - source_lines.push_str(&format!( - " \n", - key, - path.display() - )); + source_lines.push_str(&format!(" \n", key, path.display())); } for (key, pattern) in mappings { @@ -175,8 +171,7 @@ fn parse_nuget_config(content: &str) -> (Vec<(String, PathBuf)>, Vec<(String, St sources.push((cap[1].to_string(), PathBuf::from(&cap[2]))); } - let mapping_re = - regex::Regex::new(r#"\s*\s* {} } @@ -476,14 +458,7 @@ log = "0.4" "rust" => { let parent = client_path.parent().unwrap(); run_cmd( - &[ - "cargo", - "new", - "--bin", - "--name", - "quickstart_chat_client", - "client", - ], + &["cargo", "new", "--bin", "--name", "quickstart_chat_client", "client"], parent, None, )?; @@ -515,10 +490,7 @@ log = "0.4" match self.config.client_lang { "rust" => { let sdk_rust_path = workspace.join("sdks/rust"); - let sdk_rust_toml_escaped = sdk_rust_path - .display() - .to_string() - .replace('\\', "\\\\\\\\"); // double escape for toml + let sdk_rust_toml_escaped = sdk_rust_path.display().to_string().replace('\\', "\\\\\\\\"); // double escape for toml let sdk_rust_toml = format!( "spacetimedb-sdk = {{ path = \"{}\" }}\nlog = \"0.4\"\nhex = \"0.4\"\n", sdk_rust_toml_escaped @@ -569,11 +541,7 @@ log = "0.4" eprintln!("Output for {} client:\n{}", self.config.lang, output); if !output.contains(contains) { - bail!( - "Expected output to contain '{}', but got:\n{}", - contains, - output - ); + bail!("Expected output to contain '{}', but got:\n{}", contains, output); } Ok(()) } @@ -604,7 +572,10 @@ log = "0.4" let identity = caps.get(1).unwrap().as_str().to_string(); self.test.database_identity = Some(identity); } else { - bail!("Failed to parse database identity from publish output: {}", publish_output); + bail!( + "Failed to parse database identity from publish output: {}", + publish_output + ); } Ok(base_path.join("client")) @@ -635,8 +606,7 @@ log = "0.4" // Read and parse client code from documentation let doc_content = fs::read_to_string(self.doc_path())?; - let mut main_code = - parse_quickstart(&doc_content, self.config.client_lang, &self.module_name(), false); + let mut main_code = parse_quickstart(&doc_content, self.config.client_lang, &self.module_name(), false); // Apply replacements for (src, dst) in self.config.replacements { @@ -692,6 +662,5 @@ fn test_quickstart_typescript() { } let mut qt = QuickstartTest::new(QuickstartConfig::typescript()); - qt.run_quickstart() - .expect("TypeScript quickstart test failed"); + qt.run_quickstart().expect("TypeScript quickstart test failed"); } diff --git a/crates/smoketests/tests/timestamp_route.rs b/crates/smoketests/tests/timestamp_route.rs new file mode 100644 index 00000000000..a177d1e4444 --- /dev/null +++ b/crates/smoketests/tests/timestamp_route.rs @@ -0,0 +1,55 @@ +//! Tests translated from smoketests/tests/timestamp_route.py + +use spacetimedb_smoketests::{random_string, Smoketest}; + +const TIMESTAMP_TAG: &str = "__timestamp_micros_since_unix_epoch__"; + +/// Test the /v1/database/{name}/unstable/timestamp endpoint +#[test] +fn test_timestamp_route() { + let mut test = Smoketest::builder().autopublish(false).build(); + + let name = random_string(); + + // A request for the timestamp at a non-existent database is an error with code 404 + let resp = test + .api_call("GET", &format!("/v1/database/{}/unstable/timestamp", name)) + .unwrap(); + assert_eq!( + resp.status_code, 404, + "Expected 404 for non-existent database, got {}", + resp.status_code + ); + + // Publish a module with the random name + test.publish_module_named(&name, false).unwrap(); + + // A request for the timestamp at an extant database is a success + let resp = test + .api_call("GET", &format!("/v1/database/{}/unstable/timestamp", name)) + .unwrap(); + assert!( + resp.is_success(), + "Expected success for existing database, got {}", + resp.status_code + ); + + // The response body is a SATS-JSON encoded `Timestamp` + let timestamp = resp.json().unwrap(); + assert!( + timestamp.is_object(), + "Expected timestamp to be an object, got {:?}", + timestamp + ); + assert!( + timestamp.get(TIMESTAMP_TAG).is_some(), + "Expected timestamp to have '{}' field, got {:?}", + TIMESTAMP_TAG, + timestamp + ); + assert!( + timestamp[TIMESTAMP_TAG].is_i64() || timestamp[TIMESTAMP_TAG].is_u64(), + "Expected timestamp value to be an integer, got {:?}", + timestamp[TIMESTAMP_TAG] + ); +} From f658b20f77992f51721cd1eadca9114df96eba6b Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 11:13:18 -0800 Subject: [PATCH 027/118] [tyler/translate-smoketests]: fix lints --- crates/smoketests/tests/pg_wire.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/smoketests/tests/pg_wire.rs b/crates/smoketests/tests/pg_wire.rs index e1b9efeb169..71bcacd754c 100644 --- a/crates/smoketests/tests/pg_wire.rs +++ b/crates/smoketests/tests/pg_wire.rs @@ -1,3 +1,4 @@ +#![allow(clippy::disallowed_macros)] //! Tests translated from smoketests/tests/pg_wire.py use spacetimedb_smoketests::{have_psql, Smoketest}; From b8c31a92b1c2afd34e791f88deac6755666b392d Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 11:18:41 -0800 Subject: [PATCH 028/118] [tyler/translate-smoketests]: lints --- crates/smoketests/tests/csharp_module.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/smoketests/tests/csharp_module.rs b/crates/smoketests/tests/csharp_module.rs index 148a4bcb47f..7763e03227d 100644 --- a/crates/smoketests/tests/csharp_module.rs +++ b/crates/smoketests/tests/csharp_module.rs @@ -1,3 +1,4 @@ +#![allow(clippy::disallowed_macros)] //! Tests translated from smoketests/tests/csharp_module.py use spacetimedb_smoketests::{have_dotnet, workspace_root}; From da951e994493d989fc515ee40d7249fc477bf6b3 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 11:21:31 -0800 Subject: [PATCH 029/118] [tyler/translate-smoketests]: lints --- crates/smoketests/tests/quickstart.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/smoketests/tests/quickstart.rs b/crates/smoketests/tests/quickstart.rs index 48b08cf2e0e..0cc0fd899fe 100644 --- a/crates/smoketests/tests/quickstart.rs +++ b/crates/smoketests/tests/quickstart.rs @@ -1,3 +1,5 @@ +#![allow(clippy::disallowed_macros)] +#![allow(clippy::type_complexity)] //! Tests translated from smoketests/tests/quickstart.py //! //! This test validates that the quickstart documentation is correct by extracting @@ -614,7 +616,7 @@ log = "0.4" } // Add extra code - main_code.push_str("\n"); + main_code.push('\n'); main_code.push_str(self.config.extra_code); // Replace server address From 9835e1ee7f43c594ee528dcdaf4549973976bfa8 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 23 Jan 2026 15:07:26 -0500 Subject: [PATCH 030/118] Add server restart smoketests Translate server restart tests from smoketests/tests/zz_docker.py to Rust. These tests verify SpacetimeDB behavior across server restarts: - Data persistence (test_restart_module) - SQL queries after restart (test_restart_sql) - Client auto-disconnection (test_restart_auto_disconnect) - Autoinc sequence integrity (test_add_remove_index_after_restart) Infrastructure changes: - Add data_dir and restart() to SpacetimeDbGuard - Add restart_server() to Smoketest - Consolidate duplicated kill/spawn logic into helpers --- crates/guard/src/lib.rs | 221 +++++++++++++++++------- crates/smoketests/src/lib.rs | 11 ++ crates/smoketests/tests/restart.rs | 259 +++++++++++++++++++++++++++++ 3 files changed, 428 insertions(+), 63 deletions(-) create mode 100644 crates/smoketests/tests/restart.rs diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 0f4fefcad54..5dfb23a995a 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -50,7 +50,7 @@ pub fn ensure_binaries_built() -> PathBuf { let mut cmd = Command::new("cargo"); cmd.args(&args).current_dir(workspace_root); for (key, _) in env::vars() { - if key.starts_with("CARGO") && key != "CARGO_HOME" { + if key.starts_with("CARGO") && key != "CARGO_HOME" && key != "CARGO_TARGET_DIR" { cmd.env_remove(&key); } } @@ -85,6 +85,11 @@ pub struct SpacetimeDbGuard { pub logs: Arc>, /// The PostgreSQL wire protocol port, if enabled. pub pg_port: Option, + /// The data directory path (for restart scenarios). + pub data_dir: PathBuf, + /// Owns the temporary data directory (if created by spawn_in_temp_data_dir). + /// When this is Some, dropping the guard will clean up the temp dir. + data_dir_handle: Option, } // Remove all Cargo-provided env vars from a child process. These are set by the fact that we're running in a cargo @@ -100,69 +105,178 @@ impl SpacetimeDbGuard { /// Start `spacetimedb` in a temporary data directory with optional PostgreSQL wire protocol. pub fn spawn_in_temp_data_dir_with_pg_port(pg_port: Option) -> Self { let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); - let data_dir = temp_dir.path().display().to_string(); + let data_dir_path = temp_dir.path().to_path_buf(); + let data_dir_str = data_dir_path.display().to_string(); - Self::spawn_spacetime_start(false, &["start", "--data-dir", &data_dir], pg_port) + Self::spawn_spacetime_start_with_data_dir( + false, + &["start", "--data-dir", &data_dir_str], + pg_port, + data_dir_path, + Some(temp_dir), + ) } /// Start `spacetimedb` in a temporary data directory via: /// spacetime start --data-dir --listen-addr pub fn spawn_in_temp_data_dir_use_cli() -> Self { let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); - let data_dir = temp_dir.path().display().to_string(); + let data_dir_path = temp_dir.path().to_path_buf(); + let data_dir_str = data_dir_path.display().to_string(); + + Self::spawn_spacetime_start_with_data_dir( + true, + &["start", "--data-dir", &data_dir_str], + None, + data_dir_path, + Some(temp_dir), + ) + } - Self::spawn_spacetime_start(true, &["start", "--data-dir", &data_dir], None) + /// Start `spacetimedb` with an explicit data directory (for restart scenarios). + /// + /// Unlike `spawn_in_temp_data_dir`, this method does not create a temporary directory. + /// The caller is responsible for managing the data directory lifetime. + pub fn spawn_with_data_dir(data_dir: PathBuf, pg_port: Option) -> Self { + let data_dir_str = data_dir.display().to_string(); + Self::spawn_spacetime_start_with_data_dir( + false, + &["start", "--data-dir", &data_dir_str], + pg_port, + data_dir, + None, + ) } - fn spawn_spacetime_start(use_installed_cli: bool, extra_args: &[&str], pg_port: Option) -> Self { - // Ask SpacetimeDB/OS to allocate an ephemeral port. - // Using loopback avoids needing to "connect to 0.0.0.0". - let address = "127.0.0.1:0".to_string(); - let pg_port_str = pg_port.map(|p| p.to_string()); + fn spawn_spacetime_start_with_data_dir( + use_installed_cli: bool, + _extra_args: &[&str], + pg_port: Option, + data_dir: PathBuf, + data_dir_handle: Option, + ) -> Self { + if use_installed_cli { + // Use the installed CLI (rare case, mainly for spawn_in_temp_data_dir_use_cli) + let address = "127.0.0.1:0".to_string(); + let data_dir_str = data_dir.display().to_string(); + + let args = ["start", "--data-dir", &data_dir_str, "--listen-addr", &address]; + let cmd = Command::new("spacetime"); + let (child, logs) = Self::spawn_child(cmd, env!("CARGO_MANIFEST_DIR"), &args); + + let listen_addr = wait_for_listen_addr(&logs, Duration::from_secs(10)) + .unwrap_or_else(|| panic!("Timed out waiting for SpacetimeDB to report listen address")); + let host_url = format!("http://{}", listen_addr); + let guard = SpacetimeDbGuard { + child, + host_url, + logs, + pg_port, + data_dir, + data_dir_handle, + }; + guard.wait_until_http_ready(Duration::from_secs(10)); + guard + } else { + // Use the built CLI (common case) + let (child, logs, host_url) = Self::spawn_server(&data_dir, pg_port); + SpacetimeDbGuard { + child, + host_url, + logs, + pg_port, + data_dir, + data_dir_handle, + } + } + } - let mut args = vec![]; + /// Stop the server process without dropping the guard. + /// + /// This kills the server process but preserves the data directory. + /// Use `restart()` to start the server again with the same data. + pub fn stop(&mut self) { + self.kill_process(); + } - let (child, logs) = if use_installed_cli { - args.extend_from_slice(extra_args); - args.extend_from_slice(&["--listen-addr", &address]); - if let Some(ref port) = pg_port_str { - args.extend_from_slice(&["--pg-port", port]); - } + /// Restart the server with the same data directory. + /// + /// This stops the current server process and starts a new one + /// with the same data directory, preserving all data. + pub fn restart(&mut self) { + self.stop(); - let cmd = Command::new("spacetime"); - Self::spawn_child(cmd, env!("CARGO_MANIFEST_DIR"), &args) - } else { - let cli_path = ensure_binaries_built(); + let (child, logs, host_url) = Self::spawn_server(&self.data_dir, self.pg_port); - args.extend(extra_args); - args.extend(["--listen-addr", &address]); - if let Some(ref port) = pg_port_str { - args.extend(["--pg-port", port]); - } + self.child = child; + self.logs = logs; + self.host_url = host_url; + } - let cmd = Command::new(&cli_path); + /// Kills the current server process and waits for it to exit. + fn kill_process(&mut self) { + // Kill the process tree to ensure all child processes are terminated. + // On Windows, child.kill() only kills the direct child (spacetimedb-cli), + // leaving spacetimedb-standalone running as an orphan. + #[cfg(windows)] + { + let pid = self.child.id(); + let _ = Command::new("taskkill") + .args(["/F", "/T", "/PID", &pid.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_dir - .parent() - .and_then(|p| p.parent()) - .expect("Failed to find workspace root"); + #[cfg(not(windows))] + { + let _ = self.child.kill(); + } - Self::spawn_child(cmd, workspace_root.to_str().unwrap(), &args) - }; + let _ = self.child.wait(); + } - // Parse the actual bound address from logs. + /// Spawns a new server process with the given data directory. + /// Returns (child, logs, host_url). + fn spawn_server(data_dir: &PathBuf, pg_port: Option) -> (Child, Arc>, String) { + let data_dir_str = data_dir.display().to_string(); + let pg_port_str = pg_port.map(|p| p.to_string()); + + let address = "127.0.0.1:0".to_string(); + let cli_path = ensure_binaries_built(); + + let mut args = vec!["start", "--data-dir", &data_dir_str, "--listen-addr", &address]; + if let Some(ref port) = pg_port_str { + args.extend(["--pg-port", port]); + } + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() + .and_then(|p| p.parent()) + .expect("Failed to find workspace root"); + + let cmd = Command::new(&cli_path); + let (child, logs) = Self::spawn_child(cmd, workspace_root.to_str().unwrap(), &args); + + // Wait for the server to be ready let listen_addr = wait_for_listen_addr(&logs, Duration::from_secs(10)) .unwrap_or_else(|| panic!("Timed out waiting for SpacetimeDB to report listen address")); let host_url = format!("http://{}", listen_addr); - let guard = SpacetimeDbGuard { - child, - host_url, - logs, - pg_port, - }; - guard.wait_until_http_ready(Duration::from_secs(10)); - guard + + // Wait until HTTP is ready + let client = Client::new(); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + let url = format!("{}/v1/ping", host_url); + if let Ok(resp) = client.get(&url).send() { + if resp.status().is_success() { + return (child, logs, host_url); + } + } + sleep(Duration::from_millis(50)); + } + panic!("Timed out waiting for SpacetimeDB HTTP /v1/ping at {}", host_url); } fn spawn_child(mut cmd: Command, workspace_dir: &str, args: &[&str]) -> (Child, Arc>) { @@ -268,26 +382,7 @@ fn parse_listen_addr_from_line(line: &str) -> Option { impl Drop for SpacetimeDbGuard { fn drop(&mut self) { - // Kill the process tree to ensure all child processes are terminated. - // On Windows, child.kill() only kills the direct child (spacetimedb-cli), - // leaving spacetimedb-standalone running as an orphan. - #[cfg(windows)] - { - let pid = self.child.id(); - // Use taskkill /T to kill the process tree - let _ = Command::new("taskkill") - .args(["/F", "/T", "/PID", &pid.to_string()]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - } - - #[cfg(not(windows))] - { - let _ = self.child.kill(); - } - - let _ = self.child.wait(); + self.kill_process(); // Only print logs if the test is currently panicking if std::thread::panicking() { diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 6cd86f1ea0a..f504fceecbd 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -368,6 +368,17 @@ impl Smoketest { SmoketestBuilder::new() } + /// Restart the SpacetimeDB server. + /// + /// This stops the current server process and starts a new one with the + /// same data directory. All data is preserved across the restart. + /// The server URL may change since a new ephemeral port is allocated. + pub fn restart_server(&mut self) { + self.guard.restart(); + // Update server_url since the port may have changed + self.server_url = self.guard.host_url.clone(); + } + /// Returns the server host (without protocol), e.g., "127.0.0.1:3000". pub fn server_host(&self) -> &str { self.server_url diff --git a/crates/smoketests/tests/restart.rs b/crates/smoketests/tests/restart.rs new file mode 100644 index 00000000000..ab412604736 --- /dev/null +++ b/crates/smoketests/tests/restart.rs @@ -0,0 +1,259 @@ +//! Tests for server restart behavior. +//! Translated from smoketests/tests/zz_docker.py + +use spacetimedb_smoketests::Smoketest; + +const PERSON_MODULE: &str = r#" +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person, index(name = name_idx, btree(columns = [name])))] +pub struct Person { + #[primary_key] + #[auto_inc] + id: u32, + name: String, +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { id: 0, name }); +} + +#[spacetimedb::reducer] +pub fn say_hello(ctx: &ReducerContext) { + for person in ctx.db.person().iter() { + log::info!("Hello, {}!", person.name); + } + log::info!("Hello, World!"); +} +"#; + +const CONNECTED_CLIENT_MODULE: &str = r#" +use log::info; +use spacetimedb::{ConnectionId, Identity, ReducerContext, Table}; + +#[spacetimedb::table(name = connected_client)] +pub struct ConnectedClient { + identity: Identity, + connection_id: ConnectionId, +} + +#[spacetimedb::reducer(client_connected)] +fn on_connect(ctx: &ReducerContext) { + ctx.db.connected_client().insert(ConnectedClient { + identity: ctx.sender, + connection_id: ctx.connection_id.expect("sender connection id unset"), + }); +} + +#[spacetimedb::reducer(client_disconnected)] +fn on_disconnect(ctx: &ReducerContext) { + let sender_identity = &ctx.sender; + let sender_connection_id = ctx.connection_id.as_ref().expect("sender connection id unset"); + let match_client = |row: &ConnectedClient| { + &row.identity == sender_identity && &row.connection_id == sender_connection_id + }; + if let Some(client) = ctx.db.connected_client().iter().find(match_client) { + ctx.db.connected_client().delete(client); + } +} + +#[spacetimedb::reducer] +fn print_num_connected(ctx: &ReducerContext) { + let n = ctx.db.connected_client().count(); + info!("CONNECTED CLIENTS: {n}") +} +"#; + +/// Test data persistence across server restart. +/// +/// This tests to see if SpacetimeDB can be queried after a restart. +#[test] +fn test_restart_module() { + let mut test = Smoketest::builder().module_code(PERSON_MODULE).build(); + + test.call("add", &["Robert"]).unwrap(); + + test.restart_server(); + + test.call("add", &["Julie"]).unwrap(); + test.call("add", &["Samantha"]).unwrap(); + test.call("say_hello", &[]).unwrap(); + + let logs = test.logs(100).unwrap(); + assert!( + logs.iter().any(|l| l.contains("Hello, Robert!")), + "Missing 'Hello, Robert!' in logs" + ); + assert!( + logs.iter().any(|l| l.contains("Hello, Julie!")), + "Missing 'Hello, Julie!' in logs" + ); + assert!( + logs.iter().any(|l| l.contains("Hello, Samantha!")), + "Missing 'Hello, Samantha!' in logs" + ); + assert!( + logs.iter().any(|l| l.contains("Hello, World!")), + "Missing 'Hello, World!' in logs" + ); +} + +/// Test SQL queries work after restart. +#[test] +fn test_restart_sql() { + let mut test = Smoketest::builder().module_code(PERSON_MODULE).build(); + + test.call("add", &["Robert"]).unwrap(); + test.call("add", &["Julie"]).unwrap(); + test.call("add", &["Samantha"]).unwrap(); + + test.restart_server(); + + let output = test.sql("SELECT name FROM person WHERE id = 3").unwrap(); + assert!( + output.contains("Samantha"), + "Expected 'Samantha' in SQL output: {}", + output + ); +} + +/// Test clients are auto-disconnected on restart. +#[test] +fn test_restart_auto_disconnect() { + let mut test = Smoketest::builder().module_code(CONNECTED_CLIENT_MODULE).build(); + + // Start two subscribers in the background + let sub1 = test + .subscribe_background(&["SELECT * FROM connected_client"], 2) + .unwrap(); + let sub2 = test + .subscribe_background(&["SELECT * FROM connected_client"], 2) + .unwrap(); + + // Call print_num_connected and check we have 3 clients (2 subscribers + the call) + test.call("print_num_connected", &[]).unwrap(); + let logs = test.logs(10).unwrap(); + assert!( + logs.iter().any(|l| l.contains("CONNECTED CLIENTS: 3")), + "Expected 3 connected clients before restart, logs: {:?}", + logs + ); + + // Restart the server - this should disconnect all clients + test.restart_server(); + + // The subscriptions should fail/complete since the server restarted + // We don't wait for them, just drop the handles + drop(sub1); + drop(sub2); + + // After restart, only the current call should be connected + test.call("print_num_connected", &[]).unwrap(); + let logs = test.logs(10).unwrap(); + assert!( + logs.iter().any(|l| l.contains("CONNECTED CLIENTS: 1")), + "Expected 1 connected client after restart, logs: {:?}", + logs + ); +} + +// Module code for add_remove_index test (without indices) +const ADD_REMOVE_MODULE: &str = r#" +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = t1)] +pub struct T1 { id: u64 } + +#[spacetimedb::table(name = t2)] +pub struct T2 { id: u64 } + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + for id in 0..1_000 { + ctx.db.t1().insert(T1 { id }); + ctx.db.t2().insert(T2 { id }); + } +} +"#; + +// Module code for add_remove_index test (with indices) +const ADD_REMOVE_MODULE_INDEXED: &str = r#" +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = t1)] +pub struct T1 { #[index(btree)] id: u64 } + +#[spacetimedb::table(name = t2)] +pub struct T2 { #[index(btree)] id: u64 } + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + for id in 0..1_000 { + ctx.db.t1().insert(T1 { id }); + ctx.db.t2().insert(T2 { id }); + } +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext) { + let id = 1_001; + ctx.db.t1().insert(T1 { id }); + ctx.db.t2().insert(T2 { id }); +} +"#; + +const JOIN_QUERY: &str = "select t1.* from t1 join t2 on t1.id = t2.id where t2.id = 1001"; + +/// Test autoinc sequences work correctly after restart. +/// +/// This is the `AddRemoveIndex` test from add_remove_index.py, +/// but restarts the server between each publish. +/// +/// This detects a bug we once had where the system autoinc sequences +/// were borked after restart, leading newly-created database objects +/// to re-use IDs. +#[test] +fn test_add_remove_index_after_restart() { + let mut test = Smoketest::builder() + .module_code(ADD_REMOVE_MODULE) + .autopublish(false) + .build(); + + let name = format!("test-db-{}", std::process::id()); + + // Publish and attempt subscribing to a join query. + // There are no indices, resulting in an unsupported unindexed join. + test.publish_module_named(&name, false).unwrap(); + let result = test.subscribe(&[JOIN_QUERY], 0); + assert!(result.is_err(), "Expected subscription to fail without indices"); + + // Restart before adding indices + test.restart_server(); + + // Publish the indexed version. + // Now we have indices, so the query should be accepted. + test.write_module_code(ADD_REMOVE_MODULE_INDEXED).unwrap(); + test.publish_module_named(&name, false).unwrap(); + + // Subscription should work now + let result = test.subscribe(&[JOIN_QUERY], 0); + assert!( + result.is_ok(), + "Expected subscription to succeed with indices, got: {:?}", + result.err() + ); + + // Verify call works too + test.call("add", &[]).unwrap(); + + // Restart before removing indices + test.restart_server(); + + // Publish the unindexed version again, removing the index. + // The initial subscription should be rejected again. + test.write_module_code(ADD_REMOVE_MODULE).unwrap(); + test.publish_module_named(&name, false).unwrap(); + let result = test.subscribe(&[JOIN_QUERY], 0); + assert!(result.is_err(), "Expected subscription to fail after removing indices"); +} From 70bae5b94b8b669966d3d46c62392820eaa8acc6 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 12:24:09 -0800 Subject: [PATCH 031/118] [tyler/translate-smoketests]: lints --- crates/guard/src/lib.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 5dfb23a995a..f81f7f98dea 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -4,7 +4,7 @@ use std::{ env, io::{BufRead, BufReader}, net::SocketAddr, - path::PathBuf, + path::{Path, PathBuf}, process::{Child, Command, Stdio}, sync::{Arc, Mutex, OnceLock}, thread::{self, sleep}, @@ -89,7 +89,7 @@ pub struct SpacetimeDbGuard { pub data_dir: PathBuf, /// Owns the temporary data directory (if created by spawn_in_temp_data_dir). /// When this is Some, dropping the guard will clean up the temp dir. - data_dir_handle: Option, + _data_dir_handle: Option, } // Remove all Cargo-provided env vars from a child process. These are set by the fact that we're running in a cargo @@ -173,7 +173,7 @@ impl SpacetimeDbGuard { logs, pg_port, data_dir, - data_dir_handle, + _data_dir_handle: data_dir_handle, }; guard.wait_until_http_ready(Duration::from_secs(10)); guard @@ -186,7 +186,7 @@ impl SpacetimeDbGuard { logs, pg_port, data_dir, - data_dir_handle, + _data_dir_handle: data_dir_handle, } } } @@ -238,7 +238,7 @@ impl SpacetimeDbGuard { /// Spawns a new server process with the given data directory. /// Returns (child, logs, host_url). - fn spawn_server(data_dir: &PathBuf, pg_port: Option) -> (Child, Arc>, String) { + fn spawn_server(data_dir: &Path, pg_port: Option) -> (Child, Arc>, String) { let data_dir_str = data_dir.display().to_string(); let pg_port_str = pg_port.map(|p| p.to_string()); From 61cf2b8a9d2e6c17748d3ee91e09490bc08b3db7 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 12:25:44 -0800 Subject: [PATCH 032/118] [tyler/translate-smoketests]: slim lint --- crates/smoketests/tests/quickstart.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/smoketests/tests/quickstart.rs b/crates/smoketests/tests/quickstart.rs index 0cc0fd899fe..c3b02437172 100644 --- a/crates/smoketests/tests/quickstart.rs +++ b/crates/smoketests/tests/quickstart.rs @@ -1,5 +1,4 @@ #![allow(clippy::disallowed_macros)] -#![allow(clippy::type_complexity)] //! Tests translated from smoketests/tests/quickstart.py //! //! This test validates that the quickstart documentation is correct by extracting @@ -163,6 +162,7 @@ fn override_nuget_package(project_dir: &Path, package: &str, source_dir: &Path, } /// Parse an existing nuget.config file (simplified). +#[allow(clippy::type_complexity)] fn parse_nuget_config(content: &str) -> (Vec<(String, PathBuf)>, Vec<(String, String)>) { let mut sources = Vec::new(); let mut mappings = Vec::new(); From a2db6afa08410bfc5a6caf4005ee60e02c8e4248 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 23 Jan 2026 15:27:19 -0500 Subject: [PATCH 033/118] Fix clippy warnings in guard crate --- crates/guard/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index f81f7f98dea..fadb0d4dca1 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -153,7 +153,7 @@ impl SpacetimeDbGuard { _extra_args: &[&str], pg_port: Option, data_dir: PathBuf, - data_dir_handle: Option, + _data_dir_handle: Option, ) -> Self { if use_installed_cli { // Use the installed CLI (rare case, mainly for spawn_in_temp_data_dir_use_cli) @@ -173,7 +173,7 @@ impl SpacetimeDbGuard { logs, pg_port, data_dir, - _data_dir_handle: data_dir_handle, + _data_dir_handle, }; guard.wait_until_http_ready(Duration::from_secs(10)); guard @@ -186,7 +186,7 @@ impl SpacetimeDbGuard { logs, pg_port, data_dir, - _data_dir_handle: data_dir_handle, + _data_dir_handle, } } } From ab70631e530e0b6bdc55c430aa2692f1717b462a Mon Sep 17 00:00:00 2001 From: = Date: Fri, 23 Jan 2026 16:28:18 -0500 Subject: [PATCH 034/118] Remove spacetime_local in favor of explicit --server flag Simplify the Smoketest API by removing the spacetime_local method. Now spacetime() doesn't auto-add --server, and callers pass it explicitly when needed. This makes the API more consistent and explicit. --- crates/smoketests/src/lib.rs | 94 ++++++++----------- crates/smoketests/tests/delete_database.rs | 2 +- crates/smoketests/tests/describe.rs | 7 +- crates/smoketests/tests/domains.rs | 11 ++- crates/smoketests/tests/energy.rs | 2 +- .../smoketests/tests/fail_initial_publish.rs | 8 +- crates/smoketests/tests/namespaces.rs | 6 +- crates/smoketests/tests/permissions.rs | 2 +- crates/smoketests/tests/quickstart.rs | 8 +- crates/smoketests/tests/servers.rs | 14 +-- 10 files changed, 71 insertions(+), 83 deletions(-) diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index f504fceecbd..64f8641f361 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -454,31 +454,18 @@ impl Smoketest { ); } - /// Runs a spacetime CLI command with the configured server. + /// Runs a spacetime CLI command. /// /// Returns the command output. The command is run but not yet asserted. - /// The `--server` flag is automatically inserted before any `--` separator, - /// or at the end if no separator exists. + /// Uses --config-path to isolate test config from user config. + /// Callers should pass `--server` explicitly when the command needs it. pub fn spacetime_cmd(&self, args: &[&str]) -> Output { let start = Instant::now(); let cli_path = ensure_binaries_built(); - let mut cmd = Command::new(&cli_path); - - // Use test-specific config path to avoid polluting user's config - cmd.arg("--config-path").arg(&self.config_path); - - // Insert --server before any "--" separator, or at the end - // This ensures --server is processed as a flag, not a positional arg - if let Some(pos) = args.iter().position(|&a| a == "--") { - cmd.args(&args[..pos]) - .arg("--server") - .arg(&self.server_url) - .args(&args[pos..]); - } else { - cmd.args(args).arg("--server").arg(&self.server_url); - } - - let output = cmd + let output = Command::new(&cli_path) + .arg("--config-path") + .arg(&self.config_path) + .args(args) .current_dir(self.project_dir.path()) .output() .expect("Failed to execute spacetime command"); @@ -491,6 +478,7 @@ impl Smoketest { /// Runs a spacetime CLI command and returns stdout as a string. /// /// Panics if the command fails. + /// Callers should pass `--server` explicitly when the command needs it. pub fn spacetime(&self, args: &[&str]) -> Result { let output = self.spacetime_cmd(args); if !output.status.success() { @@ -504,36 +492,6 @@ impl Smoketest { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } - /// Runs a spacetime CLI command without adding the --server flag. - /// - /// Use this for local-only commands like `generate` or `server` subcommands - /// that don't need a server connection. - /// Still uses --config-path to isolate test config from user config. - pub fn spacetime_local(&self, args: &[&str]) -> Result { - let start = Instant::now(); - let cli_path = ensure_binaries_built(); - let output = Command::new(&cli_path) - .arg("--config-path") - .arg(&self.config_path) - .args(args) - .current_dir(self.project_dir.path()) - .output() - .expect("Failed to execute spacetime command"); - - let cmd_name = args.first().unwrap_or(&"unknown"); - eprintln!("[TIMING] spacetime_local {}: {:?}", cmd_name, start.elapsed()); - - if !output.status.success() { - bail!( - "spacetime {:?} failed:\nstdout: {}\nstderr: {}", - args, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - Ok(String::from_utf8_lossy(&output.stdout).to_string()) - } - /// Writes new module code to the project. pub fn write_module_code(&self, code: &str) -> Result<()> { fs::write(self.project_dir.path().join("src/lib.rs"), code).context("Failed to write module code")?; @@ -614,7 +572,14 @@ impl Smoketest { // Now publish with --bin-path to skip rebuild let publish_start = Instant::now(); - let mut args = vec!["publish", "--bin-path", &wasm_path_str, "--yes"]; + let mut args = vec![ + "publish", + "--server", + &self.server_url, + "--bin-path", + &wasm_path_str, + "--yes", + ]; if clear { args.push("--clear-database"); @@ -650,7 +615,7 @@ impl Smoketest { pub fn call(&self, name: &str, args: &[&str]) -> Result { let identity = self.database_identity.as_ref().context("No database published")?; - let mut cmd_args = vec!["call", "--", identity.as_str(), name]; + let mut cmd_args = vec!["call", "--server", &self.server_url, "--", identity.as_str(), name]; cmd_args.extend(args); self.spacetime(&cmd_args) @@ -660,7 +625,7 @@ impl Smoketest { pub fn call_output(&self, name: &str, args: &[&str]) -> Output { let identity = self.database_identity.as_ref().expect("No database published"); - let mut cmd_args = vec!["call", "--", identity.as_str(), name]; + let mut cmd_args = vec!["call", "--server", &self.server_url, "--", identity.as_str(), name]; cmd_args.extend(args); self.spacetime_cmd(&cmd_args) @@ -670,14 +635,21 @@ impl Smoketest { pub fn sql(&self, query: &str) -> Result { let identity = self.database_identity.as_ref().context("No database published")?; - self.spacetime(&["sql", identity.as_str(), query]) + self.spacetime(&["sql", "--server", &self.server_url, identity.as_str(), query]) } /// Executes a SQL query with the --confirmed flag. pub fn sql_confirmed(&self, query: &str) -> Result { let identity = self.database_identity.as_ref().context("No database published")?; - self.spacetime(&["sql", "--confirmed", identity.as_str(), query]) + self.spacetime(&[ + "sql", + "--server", + &self.server_url, + "--confirmed", + identity.as_str(), + query, + ]) } /// Asserts that a SQL query produces the expected output. @@ -708,8 +680,18 @@ impl Smoketest { /// Fetches the last N log records as JSON values. pub fn log_records(&self, n: usize) -> Result> { let identity = self.database_identity.as_ref().context("No database published")?; + let n_str = n.to_string(); - let output = self.spacetime(&["logs", "--format=json", "-n", &n.to_string(), "--", identity])?; + let output = self.spacetime(&[ + "logs", + "--server", + &self.server_url, + "--format=json", + "-n", + &n_str, + "--", + identity, + ])?; output .lines() diff --git a/crates/smoketests/tests/delete_database.rs b/crates/smoketests/tests/delete_database.rs index 8426b382074..56d89deb15f 100644 --- a/crates/smoketests/tests/delete_database.rs +++ b/crates/smoketests/tests/delete_database.rs @@ -62,7 +62,7 @@ fn test_delete_database() { thread::sleep(Duration::from_secs(2)); // Delete the database - test.spacetime(&["delete", &name]).unwrap(); + test.spacetime(&["delete", "--server", &test.server_url, &name]).unwrap(); // Collect whatever updates we got let updates = sub.collect().unwrap(); diff --git a/crates/smoketests/tests/describe.rs b/crates/smoketests/tests/describe.rs index a31357c09f7..322495ddefd 100644 --- a/crates/smoketests/tests/describe.rs +++ b/crates/smoketests/tests/describe.rs @@ -32,13 +32,14 @@ fn test_describe() { let identity = test.database_identity.as_ref().unwrap(); // Describe the whole module - test.spacetime(&["describe", "--json", identity]).unwrap(); + test.spacetime(&["describe", "--server", &test.server_url, "--json", identity]) + .unwrap(); // Describe a specific reducer - test.spacetime(&["describe", "--json", identity, "reducer", "say_hello"]) + test.spacetime(&["describe", "--server", &test.server_url, "--json", identity, "reducer", "say_hello"]) .unwrap(); // Describe a specific table - test.spacetime(&["describe", "--json", identity, "table", "person"]) + test.spacetime(&["describe", "--server", &test.server_url, "--json", identity, "table", "person"]) .unwrap(); } diff --git a/crates/smoketests/tests/domains.rs b/crates/smoketests/tests/domains.rs index 35d31202aeb..d3d9e6f6d11 100644 --- a/crates/smoketests/tests/domains.rs +++ b/crates/smoketests/tests/domains.rs @@ -13,18 +13,19 @@ fn test_set_name() { let rand_name = format!("test-db-{}-renamed", std::process::id()); // This should fail before there's a db with this name - let result = test.spacetime(&["logs", &rand_name]); + let result = test.spacetime(&["logs", "--server", &test.server_url, &rand_name]); assert!(result.is_err(), "Expected logs to fail for non-existent name"); // Rename the database let identity = test.database_identity.as_ref().unwrap(); - test.spacetime(&["rename", "--to", &rand_name, identity]).unwrap(); + test.spacetime(&["rename", "--server", &test.server_url, "--to", &rand_name, identity]) + .unwrap(); // Now logs should work with the new name - test.spacetime(&["logs", &rand_name]).unwrap(); + test.spacetime(&["logs", "--server", &test.server_url, &rand_name]).unwrap(); // Original name should no longer work - let result = test.spacetime(&["logs", &orig_name]); + let result = test.spacetime(&["logs", "--server", &test.server_url, &orig_name]); assert!(result.is_err(), "Expected logs to fail for original name after rename"); } @@ -61,7 +62,7 @@ fn test_set_to_existing_name() { test.publish_module_named(&rename_to, false).unwrap(); // Try to rename first db to the name of the second - should fail - let result = test.spacetime(&["rename", "--to", &rename_to, &id_to_rename]); + let result = test.spacetime(&["rename", "--server", &test.server_url, "--to", &rename_to, &id_to_rename]); assert!( result.is_err(), "Expected rename to fail when target name is already in use" diff --git a/crates/smoketests/tests/energy.rs b/crates/smoketests/tests/energy.rs index b25abd8e505..952a80c8c7b 100644 --- a/crates/smoketests/tests/energy.rs +++ b/crates/smoketests/tests/energy.rs @@ -8,7 +8,7 @@ use spacetimedb_smoketests::Smoketest; fn test_energy_balance() { let test = Smoketest::builder().build(); - let output = test.spacetime(&["energy", "balance"]).unwrap(); + let output = test.spacetime(&["energy", "balance", "--server", &test.server_url]).unwrap(); let re = Regex::new(r#"\{"balance":"-?[0-9]+"\}"#).unwrap(); assert!(re.is_match(&output), "Expected energy balance JSON, got: {}", output); } diff --git a/crates/smoketests/tests/fail_initial_publish.rs b/crates/smoketests/tests/fail_initial_publish.rs index a30a05e7b28..0435d09076c 100644 --- a/crates/smoketests/tests/fail_initial_publish.rs +++ b/crates/smoketests/tests/fail_initial_publish.rs @@ -64,7 +64,9 @@ fn test_fail_initial_publish() { test.write_module_code(MODULE_CODE_FIXED).unwrap(); test.publish_module_named(&name, false).unwrap(); - let describe_output = test.spacetime(&["describe", "--json", &name]).unwrap(); + let describe_output = test + .spacetime(&["describe", "--server", &test.server_url, "--json", &name]) + .unwrap(); assert!( describe_output.contains(FIXED_QUERY), "Expected describe output to contain fixed query.\nGot: {}", @@ -78,7 +80,9 @@ fn test_fail_initial_publish() { assert!(result.is_err(), "Expected publish to fail with broken module"); // Database should still exist with the fixed code - let describe_output = test.spacetime(&["describe", "--json", &name]).unwrap(); + let describe_output = test + .spacetime(&["describe", "--server", &test.server_url, "--json", &name]) + .unwrap(); assert!( describe_output.contains(FIXED_QUERY), "Expected describe output to still contain fixed query after failed update.\nGot: {}", diff --git a/crates/smoketests/tests/namespaces.rs b/crates/smoketests/tests/namespaces.rs index de63c4d952c..d83f416f381 100644 --- a/crates/smoketests/tests/namespaces.rs +++ b/crates/smoketests/tests/namespaces.rs @@ -71,8 +71,7 @@ fn test_spacetimedb_ns_csharp() { let tmpdir = tempfile::tempdir().expect("Failed to create temp dir"); let project_path = test.project_dir.path().to_str().unwrap(); - // Use spacetime_local since generate doesn't need a server connection - test.spacetime_local(&[ + test.spacetime(&[ "generate", "--out-dir", tmpdir.path().to_str().unwrap(), @@ -110,8 +109,7 @@ fn test_custom_ns_csharp() { // Use a unique namespace name let namespace = "CustomTestNamespace"; - // Use spacetime_local since generate doesn't need a server connection - test.spacetime_local(&[ + test.spacetime(&[ "generate", "--out-dir", tmpdir.path().to_str().unwrap(), diff --git a/crates/smoketests/tests/permissions.rs b/crates/smoketests/tests/permissions.rs index 76f8877fc6f..d42f902610d 100644 --- a/crates/smoketests/tests/permissions.rs +++ b/crates/smoketests/tests/permissions.rs @@ -82,7 +82,7 @@ fn test_cannot_delete_others_database() { test.new_identity().unwrap(); // Try to delete the database - should fail - let result = test.spacetime(&["delete", &identity, "--yes"]); + let result = test.spacetime(&["delete", "--server", &test.server_url, &identity, "--yes"]); assert!(result.is_err(), "Expected delete to fail for non-owner"); } diff --git a/crates/smoketests/tests/quickstart.rs b/crates/smoketests/tests/quickstart.rs index c3b02437172..48a781bc3d2 100644 --- a/crates/smoketests/tests/quickstart.rs +++ b/crates/smoketests/tests/quickstart.rs @@ -358,7 +358,7 @@ impl QuickstartTest { eprintln!("Generating server code {}: {:?}...", self.config.lang, server_path); // Initialize the project (local operation, doesn't need server) - let output = self.test.spacetime_local(&[ + let output = self.test.spacetime(&[ "init", "--non-interactive", "--lang", @@ -390,7 +390,7 @@ impl QuickstartTest { // Build the server (local operation) self.test - .spacetime_local(&["build", "-d", "-p", project_path.to_str().unwrap()])?; + .spacetime(&["build", "-d", "-p", project_path.to_str().unwrap()])?; Ok(project_path) } @@ -561,6 +561,8 @@ log = "0.4" let project_path_str = self.project_path.to_str().unwrap().to_string(); let publish_output = self.test.spacetime(&[ "publish", + "--server", + &self.test.server_url, "--project-path", &project_path_str, "--yes", @@ -596,7 +598,7 @@ log = "0.4" // Generate bindings (local operation) let bindings_path = client_path.join(self.config.module_bindings); let project_path_str = self.project_path.to_str().unwrap().to_string(); - self.test.spacetime_local(&[ + self.test.spacetime(&[ "generate", "--lang", self.config.client_lang, diff --git a/crates/smoketests/tests/servers.rs b/crates/smoketests/tests/servers.rs index b33e744a867..8ada11b0ca8 100644 --- a/crates/smoketests/tests/servers.rs +++ b/crates/smoketests/tests/servers.rs @@ -10,7 +10,7 @@ fn test_servers() { // Add a test server (local-only command, no --server flag needed) let output = test - .spacetime_local(&[ + .spacetime(&[ "server", "add", "--url", @@ -27,7 +27,7 @@ fn test_servers() { ); // List servers (local-only command) - let servers = test.spacetime_local(&["server", "list"]).unwrap(); + let servers = test.spacetime(&["server", "list"]).unwrap(); let testnet_re = Regex::new(r"(?m)^\s*testnet\.spacetimedb\.com\s+https\s+testnet\s*$").unwrap(); assert!( @@ -37,7 +37,7 @@ fn test_servers() { ); // Add the local test server to the config so we can check its fingerprint - test.spacetime_local(&[ + test.spacetime(&[ "server", "add", "--url", @@ -49,7 +49,7 @@ fn test_servers() { // Check fingerprint commands (local-only command) let output = test - .spacetime_local(&["server", "fingerprint", "test-local", "-y"]) + .spacetime(&["server", "fingerprint", "test-local", "-y"]) .unwrap(); // The exact message may vary, just check it doesn't error assert!( @@ -65,11 +65,11 @@ fn test_edit_server() { let test = Smoketest::builder().autopublish(false).build(); // Add a server to edit (local-only command) - test.spacetime_local(&["server", "add", "--url", "https://foo.com", "foo", "--no-fingerprint"]) + test.spacetime(&["server", "add", "--url", "https://foo.com", "foo", "--no-fingerprint"]) .unwrap(); // Edit the server (local-only command) - test.spacetime_local(&[ + test.spacetime(&[ "server", "edit", "foo", @@ -83,7 +83,7 @@ fn test_edit_server() { .unwrap(); // Verify the edit (local-only command) - let servers = test.spacetime_local(&["server", "list"]).unwrap(); + let servers = test.spacetime(&["server", "list"]).unwrap(); let edited_re = Regex::new(r"(?m)^\s*edited-testnet\.spacetimedb\.com\s+https\s+edited-testnet\s*$").unwrap(); assert!( edited_re.is_match(&servers), From bd146c5e2244547177bca64981e336bfc6e63bb2 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 23 Jan 2026 16:41:10 -0500 Subject: [PATCH 035/118] cargo fmt --- crates/smoketests/tests/delete_database.rs | 3 ++- crates/smoketests/tests/describe.rs | 24 ++++++++++++++++++---- crates/smoketests/tests/domains.rs | 12 +++++++++-- crates/smoketests/tests/energy.rs | 4 +++- crates/smoketests/tests/servers.rs | 4 +--- 5 files changed, 36 insertions(+), 11 deletions(-) diff --git a/crates/smoketests/tests/delete_database.rs b/crates/smoketests/tests/delete_database.rs index 56d89deb15f..fc4bcddf629 100644 --- a/crates/smoketests/tests/delete_database.rs +++ b/crates/smoketests/tests/delete_database.rs @@ -62,7 +62,8 @@ fn test_delete_database() { thread::sleep(Duration::from_secs(2)); // Delete the database - test.spacetime(&["delete", "--server", &test.server_url, &name]).unwrap(); + test.spacetime(&["delete", "--server", &test.server_url, &name]) + .unwrap(); // Collect whatever updates we got let updates = sub.collect().unwrap(); diff --git a/crates/smoketests/tests/describe.rs b/crates/smoketests/tests/describe.rs index 322495ddefd..a00b723aae6 100644 --- a/crates/smoketests/tests/describe.rs +++ b/crates/smoketests/tests/describe.rs @@ -36,10 +36,26 @@ fn test_describe() { .unwrap(); // Describe a specific reducer - test.spacetime(&["describe", "--server", &test.server_url, "--json", identity, "reducer", "say_hello"]) - .unwrap(); + test.spacetime(&[ + "describe", + "--server", + &test.server_url, + "--json", + identity, + "reducer", + "say_hello", + ]) + .unwrap(); // Describe a specific table - test.spacetime(&["describe", "--server", &test.server_url, "--json", identity, "table", "person"]) - .unwrap(); + test.spacetime(&[ + "describe", + "--server", + &test.server_url, + "--json", + identity, + "table", + "person", + ]) + .unwrap(); } diff --git a/crates/smoketests/tests/domains.rs b/crates/smoketests/tests/domains.rs index d3d9e6f6d11..df845edf63d 100644 --- a/crates/smoketests/tests/domains.rs +++ b/crates/smoketests/tests/domains.rs @@ -22,7 +22,8 @@ fn test_set_name() { .unwrap(); // Now logs should work with the new name - test.spacetime(&["logs", "--server", &test.server_url, &rand_name]).unwrap(); + test.spacetime(&["logs", "--server", &test.server_url, &rand_name]) + .unwrap(); // Original name should no longer work let result = test.spacetime(&["logs", "--server", &test.server_url, &orig_name]); @@ -62,7 +63,14 @@ fn test_set_to_existing_name() { test.publish_module_named(&rename_to, false).unwrap(); // Try to rename first db to the name of the second - should fail - let result = test.spacetime(&["rename", "--server", &test.server_url, "--to", &rename_to, &id_to_rename]); + let result = test.spacetime(&[ + "rename", + "--server", + &test.server_url, + "--to", + &rename_to, + &id_to_rename, + ]); assert!( result.is_err(), "Expected rename to fail when target name is already in use" diff --git a/crates/smoketests/tests/energy.rs b/crates/smoketests/tests/energy.rs index 952a80c8c7b..65e1eba5a1e 100644 --- a/crates/smoketests/tests/energy.rs +++ b/crates/smoketests/tests/energy.rs @@ -8,7 +8,9 @@ use spacetimedb_smoketests::Smoketest; fn test_energy_balance() { let test = Smoketest::builder().build(); - let output = test.spacetime(&["energy", "balance", "--server", &test.server_url]).unwrap(); + let output = test + .spacetime(&["energy", "balance", "--server", &test.server_url]) + .unwrap(); let re = Regex::new(r#"\{"balance":"-?[0-9]+"\}"#).unwrap(); assert!(re.is_match(&output), "Expected energy balance JSON, got: {}", output); } diff --git a/crates/smoketests/tests/servers.rs b/crates/smoketests/tests/servers.rs index 8ada11b0ca8..2c10b8bc471 100644 --- a/crates/smoketests/tests/servers.rs +++ b/crates/smoketests/tests/servers.rs @@ -48,9 +48,7 @@ fn test_servers() { .unwrap(); // Check fingerprint commands (local-only command) - let output = test - .spacetime(&["server", "fingerprint", "test-local", "-y"]) - .unwrap(); + let output = test.spacetime(&["server", "fingerprint", "test-local", "-y"]).unwrap(); // The exact message may vary, just check it doesn't error assert!( output.contains("fingerprint") || output.contains("Fingerprint"), From ba1f9923e90bd3e6dc22376942e5e943157cf408 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 15:13:49 -0800 Subject: [PATCH 036/118] temp fix --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31768e0a86e..651357dfba0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ concurrency: cancel-in-progress: true jobs: - docker_smoketests: + smoketests: # TODO: before merging re-enable this # needs: [lints] name: Smoketests @@ -36,7 +36,7 @@ jobs: container: ${{ matrix.container }} timeout-minutes: 120 env: - CARGO_TARGET_DIR: ${{ github.workspace }}/target + #CARGO_TARGET_DIR: ${{ github.workspace }}/target steps: - name: Find Git ref env: From 3c69f75fa736b31a8e99ea48772e1584cb0bf1cf Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 15:14:05 -0800 Subject: [PATCH 037/118] re-enable lints --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 651357dfba0..da7c9143a3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,7 @@ concurrency: jobs: smoketests: - # TODO: before merging re-enable this - # needs: [lints] + needs: [lints] name: Smoketests strategy: matrix: From 0f343aaaccbaced10ac09448667313cc53d79e3b Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 23 Jan 2026 15:22:45 -0800 Subject: [PATCH 038/118] fix --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da7c9143a3f..d2622c9982f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,8 @@ jobs: runs-on: ${{ matrix.runner }} container: ${{ matrix.container }} timeout-minutes: 120 - env: - #CARGO_TARGET_DIR: ${{ github.workspace }}/target + #env: + # CARGO_TARGET_DIR: ${{ github.workspace }}/target steps: - name: Find Git ref env: From bfa3dcc0c47927bfeed7f06c658d66fda28ff81b Mon Sep 17 00:00:00 2001 From: = Date: Sat, 24 Jan 2026 22:35:32 -0500 Subject: [PATCH 039/118] Moved CLI integration tests into crates/smoketests --- crates/cli/Cargo.toml | 3 - crates/cli/tests/dev.rs | 72 --------------- crates/cli/tests/server.rs | 11 --- crates/smoketests/tests/integration.rs | 2 + .../{ => smoketests}/add_remove_index.rs | 0 .../tests/{ => smoketests}/auto_inc.rs | 0 .../tests/{ => smoketests}/auto_migration.rs | 0 .../smoketests/tests/{ => smoketests}/call.rs | 0 crates/smoketests/tests/smoketests/cli/dev.rs | 87 +++++++++++++++++++ crates/smoketests/tests/smoketests/cli/mod.rs | 4 + .../tests/smoketests/cli}/publish.rs | 73 +++++++++++----- .../smoketests/tests/smoketests/cli/server.rs | 22 +++++ .../client_connection_errors.rs | 0 .../tests/{ => smoketests}/confirmed_reads.rs | 0 .../connect_disconnect_from_cli.rs | 0 .../tests/{ => smoketests}/create_project.rs | 0 .../tests/{ => smoketests}/csharp_module.rs | 12 +-- .../{ => smoketests}/default_module_clippy.rs | 0 .../tests/{ => smoketests}/delete_database.rs | 0 .../tests/{ => smoketests}/describe.rs | 0 .../{ => smoketests}/detect_wasm_bindgen.rs | 0 .../smoketests/tests/{ => smoketests}/dml.rs | 0 .../tests/{ => smoketests}/domains.rs | 0 .../tests/{ => smoketests}/energy.rs | 0 .../{ => smoketests}/fail_initial_publish.rs | 2 +- .../tests/{ => smoketests}/filtering.rs | 0 crates/smoketests/tests/smoketests/mod.rs | 35 ++++++++ .../{ => smoketests}/module_nested_op.rs | 0 .../tests/{ => smoketests}/modules.rs | 0 .../tests/{ => smoketests}/namespaces.rs | 0 .../tests/{ => smoketests}/new_user_flow.rs | 0 .../tests/{ => smoketests}/panic.rs | 0 .../tests/{ => smoketests}/permissions.rs | 0 .../tests/{ => smoketests}/pg_wire.rs | 0 .../tests/{ => smoketests}/quickstart.rs | 0 .../tests/{ => smoketests}/restart.rs | 0 .../smoketests/tests/{ => smoketests}/rls.rs | 0 .../{ => smoketests}/schedule_reducer.rs | 0 .../tests/{ => smoketests}/servers.rs | 0 .../smoketests/tests/{ => smoketests}/sql.rs | 0 .../tests/{ => smoketests}/timestamp_route.rs | 0 .../tests/{ => smoketests}/views.rs | 0 42 files changed, 204 insertions(+), 119 deletions(-) delete mode 100644 crates/cli/tests/dev.rs delete mode 100644 crates/cli/tests/server.rs create mode 100644 crates/smoketests/tests/integration.rs rename crates/smoketests/tests/{ => smoketests}/add_remove_index.rs (100%) rename crates/smoketests/tests/{ => smoketests}/auto_inc.rs (100%) rename crates/smoketests/tests/{ => smoketests}/auto_migration.rs (100%) rename crates/smoketests/tests/{ => smoketests}/call.rs (100%) create mode 100644 crates/smoketests/tests/smoketests/cli/dev.rs create mode 100644 crates/smoketests/tests/smoketests/cli/mod.rs rename crates/{cli/tests => smoketests/tests/smoketests/cli}/publish.rs (71%) create mode 100644 crates/smoketests/tests/smoketests/cli/server.rs rename crates/smoketests/tests/{ => smoketests}/client_connection_errors.rs (100%) rename crates/smoketests/tests/{ => smoketests}/confirmed_reads.rs (100%) rename crates/smoketests/tests/{ => smoketests}/connect_disconnect_from_cli.rs (100%) rename crates/smoketests/tests/{ => smoketests}/create_project.rs (100%) rename crates/smoketests/tests/{ => smoketests}/csharp_module.rs (91%) rename crates/smoketests/tests/{ => smoketests}/default_module_clippy.rs (100%) rename crates/smoketests/tests/{ => smoketests}/delete_database.rs (100%) rename crates/smoketests/tests/{ => smoketests}/describe.rs (100%) rename crates/smoketests/tests/{ => smoketests}/detect_wasm_bindgen.rs (100%) rename crates/smoketests/tests/{ => smoketests}/dml.rs (100%) rename crates/smoketests/tests/{ => smoketests}/domains.rs (100%) rename crates/smoketests/tests/{ => smoketests}/energy.rs (100%) rename crates/smoketests/tests/{ => smoketests}/fail_initial_publish.rs (96%) rename crates/smoketests/tests/{ => smoketests}/filtering.rs (100%) create mode 100644 crates/smoketests/tests/smoketests/mod.rs rename crates/smoketests/tests/{ => smoketests}/module_nested_op.rs (100%) rename crates/smoketests/tests/{ => smoketests}/modules.rs (100%) rename crates/smoketests/tests/{ => smoketests}/namespaces.rs (100%) rename crates/smoketests/tests/{ => smoketests}/new_user_flow.rs (100%) rename crates/smoketests/tests/{ => smoketests}/panic.rs (100%) rename crates/smoketests/tests/{ => smoketests}/permissions.rs (100%) rename crates/smoketests/tests/{ => smoketests}/pg_wire.rs (100%) rename crates/smoketests/tests/{ => smoketests}/quickstart.rs (100%) rename crates/smoketests/tests/{ => smoketests}/restart.rs (100%) rename crates/smoketests/tests/{ => smoketests}/rls.rs (100%) rename crates/smoketests/tests/{ => smoketests}/schedule_reducer.rs (100%) rename crates/smoketests/tests/{ => smoketests}/servers.rs (100%) rename crates/smoketests/tests/{ => smoketests}/sql.rs (100%) rename crates/smoketests/tests/{ => smoketests}/timestamp_route.rs (100%) rename crates/smoketests/tests/{ => smoketests}/views.rs (100%) diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 82a81706439..c680262a8cc 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -87,9 +87,6 @@ notify.workspace = true [dev-dependencies] pretty_assertions.workspace = true fs_extra.workspace = true -assert_cmd = "2" -predicates = "3" -spacetimedb-guard.workspace = true [target.'cfg(not(target_env = "msvc"))'.dependencies] tikv-jemallocator = { workspace = true } diff --git a/crates/cli/tests/dev.rs b/crates/cli/tests/dev.rs deleted file mode 100644 index 477546c533f..00000000000 --- a/crates/cli/tests/dev.rs +++ /dev/null @@ -1,72 +0,0 @@ -use assert_cmd::cargo::cargo_bin_cmd; -use predicates::prelude::*; - -#[test] -fn cli_dev_help_shows_template_option() { - let mut cmd = cargo_bin_cmd!("spacetimedb-cli"); - cmd.args(["dev", "--help"]) - .assert() - .success() - .stdout(predicate::str::contains("--template")) - .stdout(predicate::str::contains("-t")); -} - -#[test] -fn cli_dev_accepts_template_flag() { - // This test verifies that the CLI correctly parses the --template flag. - // We use --help after the flag to avoid actually running dev mode, - // but this still validates that the flag is recognized. - let mut cmd = cargo_bin_cmd!("spacetimedb-cli"); - // Running with an invalid server should fail, but not because of the template flag - cmd.args(["dev", "--template", "react", "--server", "nonexistent-server-12345"]) - .assert() - .failure() - // The error should be about the server, not about an unrecognized --template flag - .stderr( - predicate::str::contains("template") - .not() - .or(predicate::str::contains("unrecognized").not()), - ); -} - -#[test] -fn cli_dev_accepts_short_template_flag() { - let mut cmd = cargo_bin_cmd!("spacetimedb-cli"); - cmd.args(["dev", "-t", "typescript", "--server", "nonexistent-server-12345"]) - .assert() - .failure() - // The error should be about the server, not about an unrecognized -t flag - .stderr( - predicate::str::contains("-t") - .not() - .or(predicate::str::contains("unrecognized").not()), - ); -} - -#[test] -fn cli_init_with_template_creates_project() { - // Test that `spacetime init --template` successfully creates a project - // We use init directly since dev forwards to it for template handling - let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); - - let mut cmd = cargo_bin_cmd!("spacetimedb-cli"); - cmd.current_dir(temp_dir.path()) - .args([ - "init", - "--template", - "basic-rs", - "--local", - "--non-interactive", - "test-project", - ]) - .assert() - .success(); - - // Verify expected files were created - let project_dir = temp_dir.path().join("test-project"); - assert!( - project_dir.join("spacetimedb").exists(), - "spacetimedb directory should exist" - ); - assert!(project_dir.join("src").exists(), "src directory should exist"); -} diff --git a/crates/cli/tests/server.rs b/crates/cli/tests/server.rs deleted file mode 100644 index b2bc5fdc4ee..00000000000 --- a/crates/cli/tests/server.rs +++ /dev/null @@ -1,11 +0,0 @@ -use assert_cmd::cargo::cargo_bin_cmd; -use spacetimedb_guard::SpacetimeDbGuard; - -#[test] -fn cli_can_ping_spacetimedb_on_disk() { - let spacetime = SpacetimeDbGuard::spawn_in_temp_data_dir(); - let mut cmd = cargo_bin_cmd!("spacetimedb-cli"); - cmd.args(["server", "ping", &spacetime.host_url.to_string()]) - .assert() - .success(); -} diff --git a/crates/smoketests/tests/integration.rs b/crates/smoketests/tests/integration.rs new file mode 100644 index 00000000000..dd50b65bb8b --- /dev/null +++ b/crates/smoketests/tests/integration.rs @@ -0,0 +1,2 @@ +// Single test binary entry point - includes all smoketests +mod smoketests; diff --git a/crates/smoketests/tests/add_remove_index.rs b/crates/smoketests/tests/smoketests/add_remove_index.rs similarity index 100% rename from crates/smoketests/tests/add_remove_index.rs rename to crates/smoketests/tests/smoketests/add_remove_index.rs diff --git a/crates/smoketests/tests/auto_inc.rs b/crates/smoketests/tests/smoketests/auto_inc.rs similarity index 100% rename from crates/smoketests/tests/auto_inc.rs rename to crates/smoketests/tests/smoketests/auto_inc.rs diff --git a/crates/smoketests/tests/auto_migration.rs b/crates/smoketests/tests/smoketests/auto_migration.rs similarity index 100% rename from crates/smoketests/tests/auto_migration.rs rename to crates/smoketests/tests/smoketests/auto_migration.rs diff --git a/crates/smoketests/tests/call.rs b/crates/smoketests/tests/smoketests/call.rs similarity index 100% rename from crates/smoketests/tests/call.rs rename to crates/smoketests/tests/smoketests/call.rs diff --git a/crates/smoketests/tests/smoketests/cli/dev.rs b/crates/smoketests/tests/smoketests/cli/dev.rs new file mode 100644 index 00000000000..3447379353a --- /dev/null +++ b/crates/smoketests/tests/smoketests/cli/dev.rs @@ -0,0 +1,87 @@ +//! CLI dev command tests moved from crates/cli/tests/dev.rs + +use predicates::prelude::*; +use spacetimedb_guard::ensure_binaries_built; +use std::process::Command; + +fn cli_cmd() -> Command { + Command::new(ensure_binaries_built()) +} + +#[test] +fn cli_dev_help_shows_template_option() { + let output = cli_cmd().args(["dev", "--help"]).output().expect("failed to execute"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + predicate::str::contains("--template").eval(&stdout), + "stdout should contain --template" + ); + assert!(predicate::str::contains("-t").eval(&stdout), "stdout should contain -t"); +} + +#[test] +fn cli_dev_accepts_template_flag() { + // Running with an invalid server should fail, but not because of the template flag + let output = cli_cmd() + .args(["dev", "--template", "react", "--server", "nonexistent-server-12345"]) + .output() + .expect("failed to execute"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + // The error should be about the server, not about an unrecognized --template flag + assert!( + !stderr.contains("unrecognized") || !stderr.contains("template"), + "stderr should not complain about unrecognized template flag" + ); +} + +#[test] +fn cli_dev_accepts_short_template_flag() { + let output = cli_cmd() + .args(["dev", "-t", "typescript", "--server", "nonexistent-server-12345"]) + .output() + .expect("failed to execute"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + // The error should be about the server, not about an unrecognized -t flag + assert!( + !stderr.contains("unrecognized") || !stderr.contains("-t"), + "stderr should not complain about unrecognized -t flag" + ); +} + +#[test] +fn cli_init_with_template_creates_project() { + let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); + + let output = cli_cmd() + .current_dir(temp_dir.path()) + .args([ + "init", + "--template", + "basic-rs", + "--local", + "--non-interactive", + "test-project", + ]) + .output() + .expect("failed to execute"); + + assert!( + output.status.success(), + "init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Verify expected files were created + let project_dir = temp_dir.path().join("test-project"); + assert!( + project_dir.join("spacetimedb").exists(), + "spacetimedb directory should exist" + ); + assert!(project_dir.join("src").exists(), "src directory should exist"); +} diff --git a/crates/smoketests/tests/smoketests/cli/mod.rs b/crates/smoketests/tests/smoketests/cli/mod.rs new file mode 100644 index 00000000000..3b9e4b2b172 --- /dev/null +++ b/crates/smoketests/tests/smoketests/cli/mod.rs @@ -0,0 +1,4 @@ +// CLI integration tests moved from crates/cli/tests/ +pub mod dev; +pub mod publish; +pub mod server; diff --git a/crates/cli/tests/publish.rs b/crates/smoketests/tests/smoketests/cli/publish.rs similarity index 71% rename from crates/cli/tests/publish.rs rename to crates/smoketests/tests/smoketests/cli/publish.rs index 9e048047e07..5bdc1d69053 100644 --- a/crates/cli/tests/publish.rs +++ b/crates/smoketests/tests/smoketests/cli/publish.rs @@ -1,5 +1,11 @@ -use assert_cmd::cargo::cargo_bin_cmd; -use spacetimedb_guard::SpacetimeDbGuard; +//! CLI publish command tests moved from crates/cli/tests/publish.rs + +use spacetimedb_guard::{ensure_binaries_built, SpacetimeDbGuard}; +use std::process::Command; + +fn cli_cmd() -> Command { + Command::new(ensure_binaries_built()) +} #[test] fn cli_can_publish_spacetimedb_on_disk() { @@ -7,24 +13,34 @@ fn cli_can_publish_spacetimedb_on_disk() { // Workspace root for `cargo run -p ...` let workspace_dir = cargo_metadata::MetadataCommand::new().exec().unwrap().workspace_root; - // dir = /modules/quickstart-chat + // dir = /templates/chat-console-rs/spacetimedb let dir = workspace_dir .join("templates") .join("chat-console-rs") .join("spacetimedb"); - let mut cmd = cargo_bin_cmd!("spacetimedb-cli"); - cmd.args(["publish", "--server", &spacetime.host_url.to_string(), "foobar"]) - .current_dir(dir.clone()) - .assert() - .success(); + let output = cli_cmd() + .args(["publish", "--server", &spacetime.host_url.to_string(), "foobar"]) + .current_dir(&dir) + .output() + .expect("failed to execute"); + assert!( + output.status.success(), + "publish failed: {}", + String::from_utf8_lossy(&output.stderr) + ); // Can republish without error to the same name - let mut cmd = cargo_bin_cmd!("spacetimedb-cli"); - cmd.args(["publish", "--server", &spacetime.host_url.to_string(), "foobar"]) - .current_dir(dir) - .assert() - .success(); + let output = cli_cmd() + .args(["publish", "--server", &spacetime.host_url.to_string(), "foobar"]) + .current_dir(&dir) + .output() + .expect("failed to execute"); + assert!( + output.status.success(), + "republish failed: {}", + String::from_utf8_lossy(&output.stderr) + ); } // TODO: Somewhere we should test that data is actually deleted properly in all the expected cases, @@ -36,21 +52,32 @@ fn migration_test(module_name: &str, republish_args: &[&str], expect_success: bo let workspace_dir = cargo_metadata::MetadataCommand::new().exec().unwrap().workspace_root; let dir = workspace_dir.join("modules").join("module-test"); - let mut cmd = cargo_bin_cmd!("spacetimedb-cli"); - cmd.args(["publish", module_name, "--server", &spacetime.host_url.to_string()]) - .current_dir(dir.clone()) - .assert() - .success(); + let output = cli_cmd() + .args(["publish", module_name, "--server", &spacetime.host_url.to_string()]) + .current_dir(&dir) + .output() + .expect("failed to execute"); + assert!( + output.status.success(), + "initial publish failed: {}", + String::from_utf8_lossy(&output.stderr) + ); - let mut cmd = cargo_bin_cmd!("spacetimedb-cli"); - cmd.args(["publish", module_name, "--server", &spacetime.host_url.to_string()]) + let output = cli_cmd() + .args(["publish", module_name, "--server", &spacetime.host_url.to_string()]) .args(republish_args) - .current_dir(dir); + .current_dir(&dir) + .output() + .expect("failed to execute"); if expect_success { - cmd.assert().success(); + assert!( + output.status.success(), + "republish should have succeeded: {}", + String::from_utf8_lossy(&output.stderr) + ); } else { - cmd.assert().failure(); + assert!(!output.status.success(), "republish should have failed but succeeded"); } } diff --git a/crates/smoketests/tests/smoketests/cli/server.rs b/crates/smoketests/tests/smoketests/cli/server.rs new file mode 100644 index 00000000000..b04653836f8 --- /dev/null +++ b/crates/smoketests/tests/smoketests/cli/server.rs @@ -0,0 +1,22 @@ +//! CLI server command tests moved from crates/cli/tests/server.rs + +use spacetimedb_guard::{ensure_binaries_built, SpacetimeDbGuard}; +use std::process::Command; + +fn cli_cmd() -> Command { + Command::new(ensure_binaries_built()) +} + +#[test] +fn cli_can_ping_spacetimedb_on_disk() { + let spacetime = SpacetimeDbGuard::spawn_in_temp_data_dir(); + let output = cli_cmd() + .args(["server", "ping", &spacetime.host_url.to_string()]) + .output() + .expect("failed to execute"); + assert!( + output.status.success(), + "ping failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/smoketests/tests/client_connection_errors.rs b/crates/smoketests/tests/smoketests/client_connection_errors.rs similarity index 100% rename from crates/smoketests/tests/client_connection_errors.rs rename to crates/smoketests/tests/smoketests/client_connection_errors.rs diff --git a/crates/smoketests/tests/confirmed_reads.rs b/crates/smoketests/tests/smoketests/confirmed_reads.rs similarity index 100% rename from crates/smoketests/tests/confirmed_reads.rs rename to crates/smoketests/tests/smoketests/confirmed_reads.rs diff --git a/crates/smoketests/tests/connect_disconnect_from_cli.rs b/crates/smoketests/tests/smoketests/connect_disconnect_from_cli.rs similarity index 100% rename from crates/smoketests/tests/connect_disconnect_from_cli.rs rename to crates/smoketests/tests/smoketests/connect_disconnect_from_cli.rs diff --git a/crates/smoketests/tests/create_project.rs b/crates/smoketests/tests/smoketests/create_project.rs similarity index 100% rename from crates/smoketests/tests/create_project.rs rename to crates/smoketests/tests/smoketests/create_project.rs diff --git a/crates/smoketests/tests/csharp_module.rs b/crates/smoketests/tests/smoketests/csharp_module.rs similarity index 91% rename from crates/smoketests/tests/csharp_module.rs rename to crates/smoketests/tests/smoketests/csharp_module.rs index 7763e03227d..6feb824359f 100644 --- a/crates/smoketests/tests/csharp_module.rs +++ b/crates/smoketests/tests/smoketests/csharp_module.rs @@ -1,6 +1,7 @@ #![allow(clippy::disallowed_macros)] //! Tests translated from smoketests/tests/csharp_module.py +use spacetimedb_guard::ensure_binaries_built; use spacetimedb_smoketests::{have_dotnet, workspace_root}; use std::fs; use std::process::Command; @@ -17,15 +18,8 @@ fn test_build_csharp_module() { let workspace = workspace_root(); let bindings = workspace.join("crates/bindings-csharp"); - let cli_path = workspace.join("target/debug/spacetimedb-cli"); - - // Build the CLI if needed - let status = Command::new("cargo") - .args(["build", "-p", "spacetimedb-cli"]) - .current_dir(&workspace) - .status() - .expect("Failed to build CLI"); - assert!(status.success(), "Failed to build spacetimedb-cli"); + // CLI is pre-built by artifact dependencies during compilation + let cli_path = ensure_binaries_built(); // Clear nuget locals let status = Command::new("dotnet") diff --git a/crates/smoketests/tests/default_module_clippy.rs b/crates/smoketests/tests/smoketests/default_module_clippy.rs similarity index 100% rename from crates/smoketests/tests/default_module_clippy.rs rename to crates/smoketests/tests/smoketests/default_module_clippy.rs diff --git a/crates/smoketests/tests/delete_database.rs b/crates/smoketests/tests/smoketests/delete_database.rs similarity index 100% rename from crates/smoketests/tests/delete_database.rs rename to crates/smoketests/tests/smoketests/delete_database.rs diff --git a/crates/smoketests/tests/describe.rs b/crates/smoketests/tests/smoketests/describe.rs similarity index 100% rename from crates/smoketests/tests/describe.rs rename to crates/smoketests/tests/smoketests/describe.rs diff --git a/crates/smoketests/tests/detect_wasm_bindgen.rs b/crates/smoketests/tests/smoketests/detect_wasm_bindgen.rs similarity index 100% rename from crates/smoketests/tests/detect_wasm_bindgen.rs rename to crates/smoketests/tests/smoketests/detect_wasm_bindgen.rs diff --git a/crates/smoketests/tests/dml.rs b/crates/smoketests/tests/smoketests/dml.rs similarity index 100% rename from crates/smoketests/tests/dml.rs rename to crates/smoketests/tests/smoketests/dml.rs diff --git a/crates/smoketests/tests/domains.rs b/crates/smoketests/tests/smoketests/domains.rs similarity index 100% rename from crates/smoketests/tests/domains.rs rename to crates/smoketests/tests/smoketests/domains.rs diff --git a/crates/smoketests/tests/energy.rs b/crates/smoketests/tests/smoketests/energy.rs similarity index 100% rename from crates/smoketests/tests/energy.rs rename to crates/smoketests/tests/smoketests/energy.rs diff --git a/crates/smoketests/tests/fail_initial_publish.rs b/crates/smoketests/tests/smoketests/fail_initial_publish.rs similarity index 96% rename from crates/smoketests/tests/fail_initial_publish.rs rename to crates/smoketests/tests/smoketests/fail_initial_publish.rs index 0435d09076c..85e0a7294b7 100644 --- a/crates/smoketests/tests/fail_initial_publish.rs +++ b/crates/smoketests/tests/smoketests/fail_initial_publish.rs @@ -46,7 +46,7 @@ fn test_fail_initial_publish() { assert!(result.is_err(), "Expected publish to fail with broken module"); // Describe should fail because database doesn't exist - let describe_output = test.spacetime_cmd(&["describe", "--json", &name]); + let describe_output = test.spacetime_cmd(&["describe", "--server", &test.server_url, "--json", &name]); assert!( !describe_output.status.success(), "Expected describe to fail for non-existent database" diff --git a/crates/smoketests/tests/filtering.rs b/crates/smoketests/tests/smoketests/filtering.rs similarity index 100% rename from crates/smoketests/tests/filtering.rs rename to crates/smoketests/tests/smoketests/filtering.rs diff --git a/crates/smoketests/tests/smoketests/mod.rs b/crates/smoketests/tests/smoketests/mod.rs new file mode 100644 index 00000000000..256de070f7d --- /dev/null +++ b/crates/smoketests/tests/smoketests/mod.rs @@ -0,0 +1,35 @@ +// All smoketest modules +pub mod add_remove_index; +pub mod auto_inc; +pub mod auto_migration; +pub mod call; +pub mod cli; +pub mod client_connection_errors; +pub mod confirmed_reads; +pub mod connect_disconnect_from_cli; +pub mod create_project; +pub mod csharp_module; +pub mod default_module_clippy; +pub mod delete_database; +pub mod describe; +pub mod detect_wasm_bindgen; +pub mod dml; +pub mod domains; +pub mod energy; +pub mod fail_initial_publish; +pub mod filtering; +pub mod module_nested_op; +pub mod modules; +pub mod namespaces; +pub mod new_user_flow; +pub mod panic; +pub mod permissions; +pub mod pg_wire; +pub mod quickstart; +pub mod restart; +pub mod rls; +pub mod schedule_reducer; +pub mod servers; +pub mod sql; +pub mod timestamp_route; +pub mod views; diff --git a/crates/smoketests/tests/module_nested_op.rs b/crates/smoketests/tests/smoketests/module_nested_op.rs similarity index 100% rename from crates/smoketests/tests/module_nested_op.rs rename to crates/smoketests/tests/smoketests/module_nested_op.rs diff --git a/crates/smoketests/tests/modules.rs b/crates/smoketests/tests/smoketests/modules.rs similarity index 100% rename from crates/smoketests/tests/modules.rs rename to crates/smoketests/tests/smoketests/modules.rs diff --git a/crates/smoketests/tests/namespaces.rs b/crates/smoketests/tests/smoketests/namespaces.rs similarity index 100% rename from crates/smoketests/tests/namespaces.rs rename to crates/smoketests/tests/smoketests/namespaces.rs diff --git a/crates/smoketests/tests/new_user_flow.rs b/crates/smoketests/tests/smoketests/new_user_flow.rs similarity index 100% rename from crates/smoketests/tests/new_user_flow.rs rename to crates/smoketests/tests/smoketests/new_user_flow.rs diff --git a/crates/smoketests/tests/panic.rs b/crates/smoketests/tests/smoketests/panic.rs similarity index 100% rename from crates/smoketests/tests/panic.rs rename to crates/smoketests/tests/smoketests/panic.rs diff --git a/crates/smoketests/tests/permissions.rs b/crates/smoketests/tests/smoketests/permissions.rs similarity index 100% rename from crates/smoketests/tests/permissions.rs rename to crates/smoketests/tests/smoketests/permissions.rs diff --git a/crates/smoketests/tests/pg_wire.rs b/crates/smoketests/tests/smoketests/pg_wire.rs similarity index 100% rename from crates/smoketests/tests/pg_wire.rs rename to crates/smoketests/tests/smoketests/pg_wire.rs diff --git a/crates/smoketests/tests/quickstart.rs b/crates/smoketests/tests/smoketests/quickstart.rs similarity index 100% rename from crates/smoketests/tests/quickstart.rs rename to crates/smoketests/tests/smoketests/quickstart.rs diff --git a/crates/smoketests/tests/restart.rs b/crates/smoketests/tests/smoketests/restart.rs similarity index 100% rename from crates/smoketests/tests/restart.rs rename to crates/smoketests/tests/smoketests/restart.rs diff --git a/crates/smoketests/tests/rls.rs b/crates/smoketests/tests/smoketests/rls.rs similarity index 100% rename from crates/smoketests/tests/rls.rs rename to crates/smoketests/tests/smoketests/rls.rs diff --git a/crates/smoketests/tests/schedule_reducer.rs b/crates/smoketests/tests/smoketests/schedule_reducer.rs similarity index 100% rename from crates/smoketests/tests/schedule_reducer.rs rename to crates/smoketests/tests/smoketests/schedule_reducer.rs diff --git a/crates/smoketests/tests/servers.rs b/crates/smoketests/tests/smoketests/servers.rs similarity index 100% rename from crates/smoketests/tests/servers.rs rename to crates/smoketests/tests/smoketests/servers.rs diff --git a/crates/smoketests/tests/sql.rs b/crates/smoketests/tests/smoketests/sql.rs similarity index 100% rename from crates/smoketests/tests/sql.rs rename to crates/smoketests/tests/smoketests/sql.rs diff --git a/crates/smoketests/tests/timestamp_route.rs b/crates/smoketests/tests/smoketests/timestamp_route.rs similarity index 100% rename from crates/smoketests/tests/timestamp_route.rs rename to crates/smoketests/tests/smoketests/timestamp_route.rs diff --git a/crates/smoketests/tests/views.rs b/crates/smoketests/tests/smoketests/views.rs similarity index 100% rename from crates/smoketests/tests/views.rs rename to crates/smoketests/tests/smoketests/views.rs From 87f318c4bcf1763ed5ced8ec7863cb9309d5a85e Mon Sep 17 00:00:00 2001 From: = Date: Sat, 24 Jan 2026 23:51:51 -0500 Subject: [PATCH 040/118] Add shared target directory for faster parallel smoketests - Rename tools/xtask to tools/xtask-smoketest - Add USE_SHARED_TARGET_DIR flag to control caching behavior - When true: tests share target/smoketest-modules/ and global CARGO_HOME - When false: each test gets isolated CARGO_HOME (no sharing) - Shared mode is 1.68x faster (378s vs 636s for all tests) - Add detailed build timing instrumentation --- .cargo/config.toml | 1 + Cargo.toml | 1 + crates/smoketests/src/lib.rs | 136 ++++++++++++++++++++++++++---- tools/xtask-smoketest/Cargo.toml | 8 ++ tools/xtask-smoketest/src/main.rs | 109 ++++++++++++++++++++++++ 5 files changed, 237 insertions(+), 18 deletions(-) create mode 100644 tools/xtask-smoketest/Cargo.toml create mode 100644 tools/xtask-smoketest/src/main.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 4fc9cdf51a1..5774fce9a7a 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -5,6 +5,7 @@ rustflags = ["--cfg", "tokio_unstable"] bump-versions = "run -p upgrade-version --" llm = "run --package xtask-llm-benchmark --bin llm_benchmark --" ci = "run -p ci --" +smoketest = "run -p xtask-smoketest -- smoketest" [target.x86_64-pc-windows-msvc] # Use a different linker. Otherwise, the build fails with some obscure linker error that diff --git a/Cargo.toml b/Cargo.toml index 7a372cb46ef..ef000fa9630 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ members = [ "tools/generate-client-api", "tools/gen-bindings", "tools/xtask-llm-benchmark", + "tools/xtask-smoketest", "crates/bindings-typescript/test-app/server", "crates/bindings-typescript/test-react-router-app/server", "crates/query-builder", diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 64f8641f361..4a50baa4f84 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -4,6 +4,16 @@ //! This crate provides utilities for writing end-to-end tests that compile and publish //! SpacetimeDB modules, then exercise them via CLI commands. //! +//! # Running Smoketests +//! +//! Always run smoketests using the xtask command to ensure binaries are pre-built: +//! +//! ```bash +//! cargo smoketest # Run all smoketests +//! cargo smoketest -- test_name # Run specific tests +//! cargo xtask smoketest -- --help # See all options +//! ``` +//! //! # Example //! //! ```ignore @@ -65,6 +75,36 @@ pub fn workspace_root() -> PathBuf { .to_path_buf() } +/// Controls whether smoketests share build caches or run in complete isolation. +/// +/// - `true`: All tests share `target/smoketest-modules/` and the global `~/.cargo/` cache. +/// First test compiles dependencies, subsequent tests reuse cached artifacts. +/// +/// - `false`: Each test gets its own `CARGO_HOME` and target directory for complete isolation. +/// No lock contention between parallel tests since nothing is shared. +const USE_SHARED_TARGET_DIR: bool = true; + +/// Returns the shared target directory for smoketest module builds, if enabled. +/// +/// This directory is shared across all smoketests to cache compiled dependencies. +/// Using a shared target directory dramatically reduces build times since the +/// spacetimedb bindings and other dependencies only need to be compiled once. +fn shared_module_target_dir() -> Option { + if !USE_SHARED_TARGET_DIR { + return None; + } + static TARGET_DIR: OnceLock = OnceLock::new(); + Some( + TARGET_DIR + .get_or_init(|| { + let target_dir = workspace_root().join("target/smoketest-modules"); + fs::create_dir_all(&target_dir).expect("Failed to create shared module target directory"); + target_dir + }) + .clone(), + ) +} + /// Generates a random lowercase alphabetic string suitable for database names. pub fn random_string() -> String { use std::time::{SystemTime, UNIX_EPOCH}; @@ -196,6 +236,9 @@ pub struct Smoketest { pub server_url: String, /// Path to the test-specific CLI config file (isolates tests from user config). pub config_path: std::path::PathBuf, + /// Unique module name for this test instance. + /// Used to avoid wasm output conflicts when tests run in parallel. + module_name: String, } /// Response from an HTTP API call. @@ -285,7 +328,14 @@ impl SmoketestBuilder { /// /// This spawns a SpacetimeDB server, creates a temporary project directory, /// writes the module code, and optionally publishes the module. + /// + /// # Panics + /// + /// Panics if the CLI/standalone binaries haven't been built or are stale. + /// Run `cargo smoketest prepare` to build binaries before running tests. pub fn build(self) -> Smoketest { + // Check binaries first - this will panic with a helpful message if missing/stale + let _ = ensure_binaries_built(); let build_start = Instant::now(); let guard = timed!( @@ -296,10 +346,14 @@ impl SmoketestBuilder { let project_setup_start = Instant::now(); + // Generate a unique module name to avoid wasm output conflicts in parallel tests. + // The format is smoketest_module_{random} which produces smoketest_module_{random}.wasm + let module_name = format!("smoketest_module_{}", random_string()); + // Create project structure fs::create_dir_all(project_dir.path().join("src")).expect("Failed to create src directory"); - // Write Cargo.toml + // Write Cargo.toml with unique module name let workspace_root = workspace_root(); let bindings_path = workspace_root.join("crates/bindings"); let bindings_path_str = bindings_path.display().to_string().replace('\\', "/"); @@ -307,7 +361,7 @@ impl SmoketestBuilder { let cargo_toml = format!( r#"[package] -name = "smoketest-module" +name = "{}" version = "0.1.0" edition = "2021" @@ -319,7 +373,7 @@ spacetimedb = {{ path = "{}", features = {} }} log = "0.4" {} "#, - bindings_path_str, features_str, self.extra_deps + module_name, bindings_path_str, features_str, self.extra_deps ); fs::write(project_dir.path().join("Cargo.toml"), cargo_toml).expect("Failed to write Cargo.toml"); @@ -351,6 +405,7 @@ pub fn noop(_ctx: &ReducerContext) {} database_identity: None, server_url, config_path, + module_name, }; if self.autopublish { @@ -505,11 +560,21 @@ impl Smoketest { let start = Instant::now(); let project_path = self.project_dir.path().to_str().unwrap(); let cli_path = ensure_binaries_built(); - let output = Command::new(&cli_path) - .args(["build", "--project-path", project_path]) - .current_dir(self.project_dir.path()) - .output() - .expect("Failed to execute spacetime build"); + let mut cmd = Command::new(&cli_path); + cmd.args(["build", "--project-path", project_path]) + .current_dir(self.project_dir.path()); + + if let Some(target_dir) = shared_module_target_dir() { + // Shared mode: use shared target directory, inherit global CARGO_HOME + cmd.env("CARGO_TARGET_DIR", target_dir); + } else { + // Isolated mode: each test gets its own CARGO_HOME for complete isolation + let isolated_cargo_home = self.project_dir.path().join(".cargo-home"); + fs::create_dir_all(&isolated_cargo_home).expect("Failed to create isolated CARGO_HOME"); + cmd.env("CARGO_HOME", &isolated_cargo_home); + } + + let output = cmd.output().expect("Failed to execute spacetime build"); eprintln!("[TIMING] spacetime build: {:?}", start.elapsed()); output } @@ -548,12 +613,45 @@ impl Smoketest { // First, run spacetime build to compile the WASM module (separate from publish) let build_start = Instant::now(); let cli_path = ensure_binaries_built(); - let build_output = Command::new(&cli_path) + let target_dir = shared_module_target_dir(); + let mut build_cmd = Command::new(&cli_path); + build_cmd .args(["build", "--project-path", &project_path]) - .current_dir(self.project_dir.path()) - .output() - .expect("Failed to execute spacetime build"); - eprintln!("[TIMING] spacetime build: {:?}", build_start.elapsed()); + .current_dir(self.project_dir.path()); + + if let Some(ref dir) = target_dir { + // Shared mode: use shared target directory, inherit global CARGO_HOME + build_cmd.env("CARGO_TARGET_DIR", dir); + } else { + // Isolated mode: each test gets its own CARGO_HOME for complete isolation + let isolated_cargo_home = self.project_dir.path().join(".cargo-home"); + fs::create_dir_all(&isolated_cargo_home).expect("Failed to create isolated CARGO_HOME"); + build_cmd.env("CARGO_HOME", &isolated_cargo_home); + } + + let build_output = build_cmd.output().expect("Failed to execute spacetime build"); + let build_elapsed = build_start.elapsed(); + eprintln!("[TIMING] spacetime build: {:?}", build_elapsed); + + // In isolated mode, log detailed build breakdown from cargo output + if target_dir.is_none() { + let stderr = String::from_utf8_lossy(&build_output.stderr); + let mut downloading_count = 0; + let mut compiling_count = 0; + for line in stderr.lines() { + if line.contains("Downloading") { + downloading_count += 1; + } else if line.contains("Compiling") { + compiling_count += 1; + } else if line.contains("Blocking") || line.contains("Waiting") { + eprintln!("[BUILD] {}", line); + } + } + eprintln!( + "[BUILD DETAILS] Downloaded {} crates, Compiled {} crates in {:?}", + downloading_count, compiling_count, build_elapsed + ); + } if !build_output.status.success() { bail!( @@ -563,11 +661,13 @@ impl Smoketest { ); } - // Construct the wasm path (module name is smoketest-module -> smoketest_module.wasm) - let wasm_path = self - .project_dir - .path() - .join("target/wasm32-unknown-unknown/release/smoketest_module.wasm"); + // Construct the wasm path using the unique module name + // Use the target directory where the build output goes (shared or per-project) + let wasm_filename = format!("{}.wasm", self.module_name); + let effective_target_dir = target_dir.unwrap_or_else(|| self.project_dir.path().join("target")); + let wasm_path = effective_target_dir + .join("wasm32-unknown-unknown/release") + .join(&wasm_filename); let wasm_path_str = wasm_path.to_str().unwrap().to_string(); // Now publish with --bin-path to skip rebuild diff --git a/tools/xtask-smoketest/Cargo.toml b/tools/xtask-smoketest/Cargo.toml new file mode 100644 index 00000000000..0af68c2d6f6 --- /dev/null +++ b/tools/xtask-smoketest/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "xtask-smoketest" +version = "0.1.0" +edition.workspace = true + +[dependencies] +anyhow.workspace = true +clap.workspace = true diff --git a/tools/xtask-smoketest/src/main.rs b/tools/xtask-smoketest/src/main.rs new file mode 100644 index 00000000000..0747a98a5ca --- /dev/null +++ b/tools/xtask-smoketest/src/main.rs @@ -0,0 +1,109 @@ +use anyhow::{ensure, Result}; +use clap::{Parser, Subcommand}; +use std::env; +use std::process::{Command, Stdio}; + +/// SpacetimeDB development tasks +#[derive(Parser)] +#[command(name = "cargo xtask")] +struct Cli { + #[command(subcommand)] + cmd: XtaskCmd, +} + +#[derive(Subcommand)] +enum XtaskCmd { + /// Run smoketests with pre-built binaries + /// + /// This command first builds the spacetimedb-cli and spacetimedb-standalone binaries, + /// then runs the smoketests. This prevents race conditions when running tests in parallel + /// with nextest, where multiple test processes might try to build the same binaries + /// simultaneously. + Smoketest { + #[command(subcommand)] + cmd: Option, + + /// Additional arguments to pass to the test runner + #[arg(trailing_var_arg = true)] + args: Vec, + }, +} + +#[derive(Subcommand)] +enum SmoketestCmd { + /// Only build binaries without running tests + /// + /// Use this before running `cargo test --all` to ensure binaries are built. + Prepare, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + match cli.cmd { + XtaskCmd::Smoketest { + cmd: Some(SmoketestCmd::Prepare), + .. + } => { + build_binaries()?; + eprintln!("Binaries ready. You can now run `cargo test --all`."); + Ok(()) + } + XtaskCmd::Smoketest { cmd: None, args } => run_smoketest(args), + } +} + +fn build_binaries() -> Result<()> { + eprintln!("Building spacetimedb-cli and spacetimedb-standalone..."); + + let mut cmd = Command::new("cargo"); + cmd.args(["build", "-p", "spacetimedb-cli", "-p", "spacetimedb-standalone"]); + + // Remove cargo/rust env vars that could cause fingerprint mismatches + // when the test later runs cargo build from a different environment + for (key, _) in env::vars() { + let should_remove = (key.starts_with("CARGO") && key != "CARGO_HOME" && key != "CARGO_TARGET_DIR") + || key.starts_with("RUST") + || key == "__CARGO_FIX_YOLO"; + if should_remove { + cmd.env_remove(&key); + } + } + + let status = cmd.status()?; + ensure!(status.success(), "Failed to build binaries"); + eprintln!("Binaries built successfully.\n"); + Ok(()) +} + +fn run_smoketest(args: Vec) -> Result<()> { + // 1. Build binaries first (single process, no race) + build_binaries()?; + + // 2. Detect whether to use nextest or cargo test + let use_nextest = Command::new("cargo") + .args(["nextest", "--version"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + // 3. Run tests with appropriate runner + let status = if use_nextest { + eprintln!("Running smoketests with cargo nextest...\n"); + Command::new("cargo") + .args(["nextest", "run", "-p", "spacetimedb-smoketests"]) + .args(&args) + .status()? + } else { + eprintln!("Running smoketests with cargo test...\n"); + Command::new("cargo") + .args(["test", "-p", "spacetimedb-smoketests"]) + .args(&args) + .status()? + }; + + ensure!(status.success(), "Tests failed"); + Ok(()) +} From 4e7fee21e7662f297fd741878b414bf65845bd36 Mon Sep 17 00:00:00 2001 From: = Date: Sun, 25 Jan 2026 02:46:54 -0500 Subject: [PATCH 041/118] Add WASM cache warmup and optimize parallel smoketest execution - Add warmup_wasm_cache() to pre-compile dependencies before tests run - Use shared target directory for all tests to reuse compiled deps - Limit parallelism to 8 jobs to reduce cargo lock contention - Add --no-fail-fast to run all tests even if some fail Results: 329s total (vs 378s before), 13 slow tests (vs 35 before) --- crates/smoketests/src/lib.rs | 94 ++++++++----------------------- tools/xtask-smoketest/Cargo.toml | 1 + tools/xtask-smoketest/src/main.rs | 79 ++++++++++++++++++++++++-- 3 files changed, 96 insertions(+), 78 deletions(-) diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 4a50baa4f84..e97b7436b10 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -75,34 +75,21 @@ pub fn workspace_root() -> PathBuf { .to_path_buf() } -/// Controls whether smoketests share build caches or run in complete isolation. +/// Returns the shared target directory for smoketest module builds. /// -/// - `true`: All tests share `target/smoketest-modules/` and the global `~/.cargo/` cache. -/// First test compiles dependencies, subsequent tests reuse cached artifacts. -/// -/// - `false`: Each test gets its own `CARGO_HOME` and target directory for complete isolation. -/// No lock contention between parallel tests since nothing is shared. -const USE_SHARED_TARGET_DIR: bool = true; - -/// Returns the shared target directory for smoketest module builds, if enabled. -/// -/// This directory is shared across all smoketests to cache compiled dependencies. -/// Using a shared target directory dramatically reduces build times since the -/// spacetimedb bindings and other dependencies only need to be compiled once. -fn shared_module_target_dir() -> Option { - if !USE_SHARED_TARGET_DIR { - return None; - } +/// All tests share this directory to cache compiled dependencies. The warmup step +/// pre-compiles dependencies, then each test only needs to compile its unique module. +/// Cargo serializes builds due to directory locking, but this is still faster than +/// each test compiling all dependencies from scratch. +fn shared_target_dir() -> PathBuf { static TARGET_DIR: OnceLock = OnceLock::new(); - Some( - TARGET_DIR - .get_or_init(|| { - let target_dir = workspace_root().join("target/smoketest-modules"); - fs::create_dir_all(&target_dir).expect("Failed to create shared module target directory"); - target_dir - }) - .clone(), - ) + TARGET_DIR + .get_or_init(|| { + let target_dir = workspace_root().join("target/smoketest-modules"); + fs::create_dir_all(&target_dir).expect("Failed to create shared module target directory"); + target_dir + }) + .clone() } /// Generates a random lowercase alphabetic string suitable for database names. @@ -165,6 +152,7 @@ pub fn have_pnpm() -> bool { }) } + /// Parse code blocks from quickstart markdown documentation. /// Extracts code blocks with the specified language tag. /// @@ -560,19 +548,11 @@ impl Smoketest { let start = Instant::now(); let project_path = self.project_dir.path().to_str().unwrap(); let cli_path = ensure_binaries_built(); + let mut cmd = Command::new(&cli_path); cmd.args(["build", "--project-path", project_path]) - .current_dir(self.project_dir.path()); - - if let Some(target_dir) = shared_module_target_dir() { - // Shared mode: use shared target directory, inherit global CARGO_HOME - cmd.env("CARGO_TARGET_DIR", target_dir); - } else { - // Isolated mode: each test gets its own CARGO_HOME for complete isolation - let isolated_cargo_home = self.project_dir.path().join(".cargo-home"); - fs::create_dir_all(&isolated_cargo_home).expect("Failed to create isolated CARGO_HOME"); - cmd.env("CARGO_HOME", &isolated_cargo_home); - } + .current_dir(self.project_dir.path()) + .env("CARGO_TARGET_DIR", shared_target_dir()); let output = cmd.output().expect("Failed to execute spacetime build"); eprintln!("[TIMING] spacetime build: {:?}", start.elapsed()); @@ -613,46 +593,18 @@ impl Smoketest { // First, run spacetime build to compile the WASM module (separate from publish) let build_start = Instant::now(); let cli_path = ensure_binaries_built(); - let target_dir = shared_module_target_dir(); + let target_dir = shared_target_dir(); + let mut build_cmd = Command::new(&cli_path); build_cmd .args(["build", "--project-path", &project_path]) - .current_dir(self.project_dir.path()); - - if let Some(ref dir) = target_dir { - // Shared mode: use shared target directory, inherit global CARGO_HOME - build_cmd.env("CARGO_TARGET_DIR", dir); - } else { - // Isolated mode: each test gets its own CARGO_HOME for complete isolation - let isolated_cargo_home = self.project_dir.path().join(".cargo-home"); - fs::create_dir_all(&isolated_cargo_home).expect("Failed to create isolated CARGO_HOME"); - build_cmd.env("CARGO_HOME", &isolated_cargo_home); - } + .current_dir(self.project_dir.path()) + .env("CARGO_TARGET_DIR", &target_dir); let build_output = build_cmd.output().expect("Failed to execute spacetime build"); let build_elapsed = build_start.elapsed(); eprintln!("[TIMING] spacetime build: {:?}", build_elapsed); - // In isolated mode, log detailed build breakdown from cargo output - if target_dir.is_none() { - let stderr = String::from_utf8_lossy(&build_output.stderr); - let mut downloading_count = 0; - let mut compiling_count = 0; - for line in stderr.lines() { - if line.contains("Downloading") { - downloading_count += 1; - } else if line.contains("Compiling") { - compiling_count += 1; - } else if line.contains("Blocking") || line.contains("Waiting") { - eprintln!("[BUILD] {}", line); - } - } - eprintln!( - "[BUILD DETAILS] Downloaded {} crates, Compiled {} crates in {:?}", - downloading_count, compiling_count, build_elapsed - ); - } - if !build_output.status.success() { bail!( "spacetime build failed:\nstdout: {}\nstderr: {}", @@ -662,10 +614,8 @@ impl Smoketest { } // Construct the wasm path using the unique module name - // Use the target directory where the build output goes (shared or per-project) let wasm_filename = format!("{}.wasm", self.module_name); - let effective_target_dir = target_dir.unwrap_or_else(|| self.project_dir.path().join("target")); - let wasm_path = effective_target_dir + let wasm_path = target_dir .join("wasm32-unknown-unknown/release") .join(&wasm_filename); let wasm_path_str = wasm_path.to_str().unwrap().to_string(); diff --git a/tools/xtask-smoketest/Cargo.toml b/tools/xtask-smoketest/Cargo.toml index 0af68c2d6f6..d7f7cdd2774 100644 --- a/tools/xtask-smoketest/Cargo.toml +++ b/tools/xtask-smoketest/Cargo.toml @@ -6,3 +6,4 @@ edition.workspace = true [dependencies] anyhow.workspace = true clap.workspace = true +tempfile.workspace = true diff --git a/tools/xtask-smoketest/src/main.rs b/tools/xtask-smoketest/src/main.rs index 0747a98a5ca..b47214c571d 100644 --- a/tools/xtask-smoketest/src/main.rs +++ b/tools/xtask-smoketest/src/main.rs @@ -1,6 +1,7 @@ use anyhow::{ensure, Result}; use clap::{Parser, Subcommand}; use std::env; +use std::fs; use std::process::{Command, Stdio}; /// SpacetimeDB development tasks @@ -76,11 +77,72 @@ fn build_binaries() -> Result<()> { Ok(()) } +fn warmup_wasm_cache() -> Result<()> { + eprintln!("Warming WASM dependency cache..."); + + let workspace_root = env::current_dir()?; + let target_dir = workspace_root.join("target/smoketest-modules"); + fs::create_dir_all(&target_dir)?; + + let temp_dir = tempfile::tempdir()?; + + // Write minimal Cargo.toml that depends on spacetimedb bindings + let bindings_path = workspace_root.join("crates/bindings"); + let cargo_toml = format!( + r#"[package] +name = "warmup" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb = {{ path = "{}" }} +"#, + bindings_path.display().to_string().replace('\\', "/") + ); + fs::write(temp_dir.path().join("Cargo.toml"), cargo_toml)?; + + // Copy rust-toolchain.toml if it exists + let toolchain_src = workspace_root.join("rust-toolchain.toml"); + if toolchain_src.exists() { + fs::copy(&toolchain_src, temp_dir.path().join("rust-toolchain.toml"))?; + } + + // Write minimal lib.rs + fs::create_dir_all(temp_dir.path().join("src"))?; + fs::write(temp_dir.path().join("src/lib.rs"), "")?; + + // Build to warm the cache + let status = Command::new("cargo") + .args([ + "build", + "--target", + "wasm32-unknown-unknown", + "--release", + ]) + .env("CARGO_TARGET_DIR", &target_dir) + .current_dir(temp_dir.path()) + .status()?; + + ensure!(status.success(), "Failed to warm WASM cache"); + eprintln!("WASM cache warmed.\n"); + Ok(()) +} + +/// Default parallelism for smoketests. +/// Limited to avoid cargo build lock contention when many tests run simultaneously. +const DEFAULT_PARALLELISM: &str = "8"; + fn run_smoketest(args: Vec) -> Result<()> { // 1. Build binaries first (single process, no race) build_binaries()?; - // 2. Detect whether to use nextest or cargo test + // 2. Warm the WASM dependency cache (single process, no race) + warmup_wasm_cache()?; + + // 3. Detect whether to use nextest or cargo test let use_nextest = Command::new("cargo") .args(["nextest", "--version"]) .stdout(Stdio::null()) @@ -89,13 +151,18 @@ fn run_smoketest(args: Vec) -> Result<()> { .map(|s| s.success()) .unwrap_or(false); - // 3. Run tests with appropriate runner + // 4. Run tests with appropriate runner let status = if use_nextest { eprintln!("Running smoketests with cargo nextest...\n"); - Command::new("cargo") - .args(["nextest", "run", "-p", "spacetimedb-smoketests"]) - .args(&args) - .status()? + let mut cmd = Command::new("cargo"); + cmd.args(["nextest", "run", "-p", "spacetimedb-smoketests", "--no-fail-fast"]); + + // Set default parallelism if user didn't specify -j + if !args.iter().any(|a| a == "-j" || a.starts_with("-j") || a.starts_with("--jobs")) { + cmd.args(["-j", DEFAULT_PARALLELISM]); + } + + cmd.args(&args).status()? } else { eprintln!("Running smoketests with cargo test...\n"); Command::new("cargo") From 5a8107f633d5aec928350ce839d29b3b1add8422 Mon Sep 17 00:00:00 2001 From: = Date: Sun, 25 Jan 2026 15:46:14 -0500 Subject: [PATCH 042/118] Add precompiled WASM modules for smoketests Extract static smoketest modules into a nested workspace at crates/smoketests/modules/ that is pre-compiled during warmup. This eliminates per-test WASM compilation overhead. Key changes: - Add 38 precompiled module crates in nested workspace - Add module registry (src/modules.rs) for WASM path lookup - Add precompiled_module() builder and use_precompiled_module() method - Update xtask warmup to build nested workspace - Migrate all static tests to use precompiled modules - Tests using precompiled modules run in ~0.5-3s vs ~4-7s before Tests that need runtime compilation (auto_migration, detect_wasm_bindgen, intentionally-broken modules) continue to use module_code(). --- Cargo.lock | 14 +- Cargo.toml | 1 + crates/guard/src/lib.rs | 344 ++++-- crates/smoketests/Cargo.toml | 2 + crates/smoketests/DEVELOP.md | 51 +- crates/smoketests/TEST_DURATIONS.md | 92 ++ crates/smoketests/modules/Cargo.lock | 1018 +++++++++++++++++ crates/smoketests/modules/Cargo.toml | 82 ++ .../add-remove-index-indexed/Cargo.toml | 12 + .../add-remove-index-indexed/src/lib.rs | 22 + .../modules/add-remove-index/Cargo.toml | 12 + .../modules/add-remove-index/src/lib.rs | 15 + .../modules/autoinc-basic-i32/Cargo.toml | 12 + .../modules/autoinc-basic-i32/src/lib.rs | 23 + .../modules/autoinc-basic-i64/Cargo.toml | 12 + .../modules/autoinc-basic-i64/src/lib.rs | 23 + .../modules/autoinc-basic-u32/Cargo.toml | 12 + .../modules/autoinc-basic-u32/src/lib.rs | 23 + .../modules/autoinc-basic-u64/Cargo.toml | 12 + .../modules/autoinc-basic-u64/src/lib.rs | 23 + .../modules/autoinc-unique-i64/Cargo.toml | 12 + .../modules/autoinc-unique-i64/src/lib.rs | 33 + .../modules/autoinc-unique-u64/Cargo.toml | 12 + .../modules/autoinc-unique-u64/src/lib.rs | 33 + .../smoketests/modules/call-empty/Cargo.toml | 12 + .../smoketests/modules/call-empty/src/lib.rs | 4 + .../modules/call-reducer-procedure/Cargo.toml | 12 + .../modules/call-reducer-procedure/src/lib.rs | 16 + .../Cargo.toml | 12 + .../src/lib.rs | 23 + .../client-connection-reject/Cargo.toml | 12 + .../client-connection-reject/src/lib.rs | 23 + .../modules/confirmed-reads/Cargo.toml | 12 + .../modules/confirmed-reads/src/lib.rs | 11 + .../modules/connect-disconnect/Cargo.toml | 12 + .../modules/connect-disconnect/src/lib.rs | 16 + .../modules/delete-database/Cargo.toml | 12 + .../modules/delete-database/src/lib.rs | 37 + crates/smoketests/modules/describe/Cargo.toml | 12 + crates/smoketests/modules/describe/src/lib.rs | 19 + crates/smoketests/modules/dml/Cargo.toml | 12 + crates/smoketests/modules/dml/src/lib.rs | 4 + .../fail-initial-publish-broken/Cargo.toml | 12 + .../fail-initial-publish-broken/src/lib.rs | 10 + .../fail-initial-publish-fixed/Cargo.toml | 12 + .../fail-initial-publish-fixed/src/lib.rs | 9 + .../smoketests/modules/filtering/Cargo.toml | 12 + .../smoketests/modules/filtering/src/lib.rs | 182 +++ .../modules/module-nested-op/Cargo.toml | 12 + .../modules/module-nested-op/src/lib.rs | 39 + .../modules/modules-add-table/Cargo.toml | 12 + .../modules/modules-add-table/src/lib.rs | 19 + .../modules/modules-basic/Cargo.toml | 12 + .../modules/modules-basic/src/lib.rs | 22 + .../modules/modules-breaking/Cargo.toml | 12 + .../modules/modules-breaking/src/lib.rs | 8 + .../smoketests/modules/namespaces/Cargo.toml | 12 + .../smoketests/modules/namespaces/src/lib.rs | 34 + .../modules/new-user-flow/Cargo.toml | 12 + .../modules/new-user-flow/src/lib.rs | 19 + .../smoketests/modules/panic-error/Cargo.toml | 12 + .../smoketests/modules/panic-error/src/lib.rs | 6 + crates/smoketests/modules/panic/Cargo.toml | 12 + crates/smoketests/modules/panic/src/lib.rs | 18 + .../modules/permissions-lifecycle/Cargo.toml | 12 + .../modules/permissions-lifecycle/src/lib.rs | 8 + .../modules/permissions-private/Cargo.toml | 12 + .../modules/permissions-private/src/lib.rs | 22 + crates/smoketests/modules/pg-wire/Cargo.toml | 12 + crates/smoketests/modules/pg-wire/src/lib.rs | 159 +++ .../restart-connected-client/Cargo.toml | 12 + .../restart-connected-client/src/lib.rs | 34 + .../modules/restart-person/Cargo.toml | 12 + .../modules/restart-person/src/lib.rs | 22 + crates/smoketests/modules/rls/Cargo.toml | 12 + crates/smoketests/modules/rls/src/lib.rs | 17 + .../modules/schedule-cancel/Cargo.toml | 12 + .../modules/schedule-cancel/src/lib.rs | 37 + .../modules/schedule-subscribe/Cargo.toml | 12 + .../modules/schedule-subscribe/src/lib.rs | 25 + .../modules/schedule-volatile/Cargo.toml | 12 + .../modules/schedule-volatile/src/lib.rs | 16 + .../smoketests/modules/sql-format/Cargo.toml | 12 + .../smoketests/modules/sql-format/src/lib.rs | 122 ++ .../smoketests/modules/views-basic/Cargo.toml | 12 + .../smoketests/modules/views-basic/src/lib.rs | 15 + .../modules/views-broken-namespace/Cargo.toml | 12 + .../modules/views-broken-namespace/src/lib.rs | 11 + .../views-broken-return-type/Cargo.toml | 12 + .../views-broken-return-type/src/lib.rs | 13 + .../smoketests/modules/views-sql/Cargo.toml | 12 + .../smoketests/modules/views-sql/src/lib.rs | 59 + crates/smoketests/src/lib.rs | 230 +++- crates/smoketests/src/modules.rs | 147 +++ .../tests/smoketests/add_remove_index.rs | 49 +- .../smoketests/tests/smoketests/auto_inc.rs | 241 ++-- crates/smoketests/tests/smoketests/call.rs | 34 +- .../smoketests/client_connection_errors.rs | 56 +- .../tests/smoketests/confirmed_reads.rs | 18 +- .../smoketests/connect_disconnect_from_cli.rs | 21 +- .../tests/smoketests/delete_database.rs | 42 +- .../smoketests/tests/smoketests/describe.rs | 24 +- crates/smoketests/tests/smoketests/dml.rs | 11 +- .../tests/smoketests/fail_initial_publish.rs | 15 +- .../smoketests/tests/smoketests/filtering.rs | 187 +-- .../tests/smoketests/module_nested_op.rs | 44 +- crates/smoketests/tests/smoketests/modules.rs | 54 +- .../smoketests/tests/smoketests/namespaces.rs | 24 +- .../tests/smoketests/new_user_flow.rs | 24 +- crates/smoketests/tests/smoketests/panic.rs | 34 +- .../tests/smoketests/permissions.rs | 40 +- crates/smoketests/tests/smoketests/pg_wire.rs | 166 +-- crates/smoketests/tests/smoketests/restart.rs | 119 +- crates/smoketests/tests/smoketests/rls.rs | 22 +- .../tests/smoketests/schedule_reducer.rs | 95 +- crates/smoketests/tests/smoketests/sql.rs | 127 +- crates/smoketests/tests/smoketests/views.rs | 86 +- tools/xtask-smoketest/src/main.rs | 35 +- 118 files changed, 3831 insertions(+), 1496 deletions(-) create mode 100644 crates/smoketests/TEST_DURATIONS.md create mode 100644 crates/smoketests/modules/Cargo.lock create mode 100644 crates/smoketests/modules/Cargo.toml create mode 100644 crates/smoketests/modules/add-remove-index-indexed/Cargo.toml create mode 100644 crates/smoketests/modules/add-remove-index-indexed/src/lib.rs create mode 100644 crates/smoketests/modules/add-remove-index/Cargo.toml create mode 100644 crates/smoketests/modules/add-remove-index/src/lib.rs create mode 100644 crates/smoketests/modules/autoinc-basic-i32/Cargo.toml create mode 100644 crates/smoketests/modules/autoinc-basic-i32/src/lib.rs create mode 100644 crates/smoketests/modules/autoinc-basic-i64/Cargo.toml create mode 100644 crates/smoketests/modules/autoinc-basic-i64/src/lib.rs create mode 100644 crates/smoketests/modules/autoinc-basic-u32/Cargo.toml create mode 100644 crates/smoketests/modules/autoinc-basic-u32/src/lib.rs create mode 100644 crates/smoketests/modules/autoinc-basic-u64/Cargo.toml create mode 100644 crates/smoketests/modules/autoinc-basic-u64/src/lib.rs create mode 100644 crates/smoketests/modules/autoinc-unique-i64/Cargo.toml create mode 100644 crates/smoketests/modules/autoinc-unique-i64/src/lib.rs create mode 100644 crates/smoketests/modules/autoinc-unique-u64/Cargo.toml create mode 100644 crates/smoketests/modules/autoinc-unique-u64/src/lib.rs create mode 100644 crates/smoketests/modules/call-empty/Cargo.toml create mode 100644 crates/smoketests/modules/call-empty/src/lib.rs create mode 100644 crates/smoketests/modules/call-reducer-procedure/Cargo.toml create mode 100644 crates/smoketests/modules/call-reducer-procedure/src/lib.rs create mode 100644 crates/smoketests/modules/client-connection-disconnect-panic/Cargo.toml create mode 100644 crates/smoketests/modules/client-connection-disconnect-panic/src/lib.rs create mode 100644 crates/smoketests/modules/client-connection-reject/Cargo.toml create mode 100644 crates/smoketests/modules/client-connection-reject/src/lib.rs create mode 100644 crates/smoketests/modules/confirmed-reads/Cargo.toml create mode 100644 crates/smoketests/modules/confirmed-reads/src/lib.rs create mode 100644 crates/smoketests/modules/connect-disconnect/Cargo.toml create mode 100644 crates/smoketests/modules/connect-disconnect/src/lib.rs create mode 100644 crates/smoketests/modules/delete-database/Cargo.toml create mode 100644 crates/smoketests/modules/delete-database/src/lib.rs create mode 100644 crates/smoketests/modules/describe/Cargo.toml create mode 100644 crates/smoketests/modules/describe/src/lib.rs create mode 100644 crates/smoketests/modules/dml/Cargo.toml create mode 100644 crates/smoketests/modules/dml/src/lib.rs create mode 100644 crates/smoketests/modules/fail-initial-publish-broken/Cargo.toml create mode 100644 crates/smoketests/modules/fail-initial-publish-broken/src/lib.rs create mode 100644 crates/smoketests/modules/fail-initial-publish-fixed/Cargo.toml create mode 100644 crates/smoketests/modules/fail-initial-publish-fixed/src/lib.rs create mode 100644 crates/smoketests/modules/filtering/Cargo.toml create mode 100644 crates/smoketests/modules/filtering/src/lib.rs create mode 100644 crates/smoketests/modules/module-nested-op/Cargo.toml create mode 100644 crates/smoketests/modules/module-nested-op/src/lib.rs create mode 100644 crates/smoketests/modules/modules-add-table/Cargo.toml create mode 100644 crates/smoketests/modules/modules-add-table/src/lib.rs create mode 100644 crates/smoketests/modules/modules-basic/Cargo.toml create mode 100644 crates/smoketests/modules/modules-basic/src/lib.rs create mode 100644 crates/smoketests/modules/modules-breaking/Cargo.toml create mode 100644 crates/smoketests/modules/modules-breaking/src/lib.rs create mode 100644 crates/smoketests/modules/namespaces/Cargo.toml create mode 100644 crates/smoketests/modules/namespaces/src/lib.rs create mode 100644 crates/smoketests/modules/new-user-flow/Cargo.toml create mode 100644 crates/smoketests/modules/new-user-flow/src/lib.rs create mode 100644 crates/smoketests/modules/panic-error/Cargo.toml create mode 100644 crates/smoketests/modules/panic-error/src/lib.rs create mode 100644 crates/smoketests/modules/panic/Cargo.toml create mode 100644 crates/smoketests/modules/panic/src/lib.rs create mode 100644 crates/smoketests/modules/permissions-lifecycle/Cargo.toml create mode 100644 crates/smoketests/modules/permissions-lifecycle/src/lib.rs create mode 100644 crates/smoketests/modules/permissions-private/Cargo.toml create mode 100644 crates/smoketests/modules/permissions-private/src/lib.rs create mode 100644 crates/smoketests/modules/pg-wire/Cargo.toml create mode 100644 crates/smoketests/modules/pg-wire/src/lib.rs create mode 100644 crates/smoketests/modules/restart-connected-client/Cargo.toml create mode 100644 crates/smoketests/modules/restart-connected-client/src/lib.rs create mode 100644 crates/smoketests/modules/restart-person/Cargo.toml create mode 100644 crates/smoketests/modules/restart-person/src/lib.rs create mode 100644 crates/smoketests/modules/rls/Cargo.toml create mode 100644 crates/smoketests/modules/rls/src/lib.rs create mode 100644 crates/smoketests/modules/schedule-cancel/Cargo.toml create mode 100644 crates/smoketests/modules/schedule-cancel/src/lib.rs create mode 100644 crates/smoketests/modules/schedule-subscribe/Cargo.toml create mode 100644 crates/smoketests/modules/schedule-subscribe/src/lib.rs create mode 100644 crates/smoketests/modules/schedule-volatile/Cargo.toml create mode 100644 crates/smoketests/modules/schedule-volatile/src/lib.rs create mode 100644 crates/smoketests/modules/sql-format/Cargo.toml create mode 100644 crates/smoketests/modules/sql-format/src/lib.rs create mode 100644 crates/smoketests/modules/views-basic/Cargo.toml create mode 100644 crates/smoketests/modules/views-basic/src/lib.rs create mode 100644 crates/smoketests/modules/views-broken-namespace/Cargo.toml create mode 100644 crates/smoketests/modules/views-broken-namespace/src/lib.rs create mode 100644 crates/smoketests/modules/views-broken-return-type/Cargo.toml create mode 100644 crates/smoketests/modules/views-broken-return-type/src/lib.rs create mode 100644 crates/smoketests/modules/views-sql/Cargo.toml create mode 100644 crates/smoketests/modules/views-sql/src/lib.rs create mode 100644 crates/smoketests/src/modules.rs diff --git a/Cargo.lock b/Cargo.lock index a2dea2be560..768bd8d1639 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7436,7 +7436,6 @@ name = "spacetimedb-cli" version = "1.11.3" dependencies = [ "anyhow", - "assert_cmd", "base64 0.21.7", "bytes", "cargo_metadata", @@ -7462,7 +7461,6 @@ dependencies = [ "names", "notify 7.0.0", "percent-encoding", - "predicates", "pretty_assertions", "quick-xml 0.31.0", "regex", @@ -7479,7 +7477,6 @@ dependencies = [ "spacetimedb-codegen", "spacetimedb-data-structures", "spacetimedb-fs-utils", - "spacetimedb-guard", "spacetimedb-jsonwebtoken", "spacetimedb-lib 1.11.3", "spacetimedb-paths", @@ -8211,7 +8208,9 @@ name = "spacetimedb-smoketests" version = "1.11.3" dependencies = [ "anyhow", + "assert_cmd", "cargo_metadata", + "predicates", "regex", "serde_json", "spacetimedb-guard", @@ -11034,6 +11033,15 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "xtask-smoketest" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap 4.5.50", + "tempfile", +] + [[package]] name = "xxhash-rust" version = "0.8.15" diff --git a/Cargo.toml b/Cargo.toml index ef000fa9630..f49f42bd2d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,5 @@ [workspace] +exclude = ["crates/smoketests/modules"] members = [ "crates/auth", "crates/bench", diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index fadb0d4dca1..22246fbbaed 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -6,72 +6,84 @@ use std::{ net::SocketAddr, path::{Path, PathBuf}, process::{Child, Command, Stdio}, - sync::{Arc, Mutex, OnceLock}, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, OnceLock, + }, thread::{self, sleep}, time::{Duration, Instant}, }; +/// Global counter for spawn IDs to correlate log messages across threads. +static SPAWN_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn next_spawn_id() -> u64 { + SPAWN_COUNTER.fetch_add(1, Ordering::SeqCst) +} + +/// Returns the workspace root directory. +fn workspace_root() -> PathBuf { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest_dir + .parent() // crates/ + .and_then(|p| p.parent()) // workspace root + .expect("Failed to find workspace root") + .to_path_buf() +} + +/// Returns the target directory. +fn target_dir() -> PathBuf { + let workspace_root = workspace_root(); + env::var("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| workspace_root.join("target")) +} + +/// Returns the expected CLI binary path. +fn cli_binary_path() -> PathBuf { + let profile = if cfg!(debug_assertions) { "debug" } else { "release" }; + let cli_name = if cfg!(windows) { + "spacetimedb-cli.exe" + } else { + "spacetimedb-cli" + }; + target_dir().join(profile).join(cli_name) +} + /// Lazily-initialized path to the pre-built CLI binary. static CLI_BINARY_PATH: OnceLock = OnceLock::new(); -/// Ensures `spacetimedb-cli` and `spacetimedb-standalone` are built once, -/// returning the path to the CLI binary. +/// Returns the path to the pre-built CLI binary. +/// +/// **This function does NOT build anything.** The binary must already exist. +/// Use `cargo smoketest` to build binaries before running tests. /// -/// This is useful for tests that need to run CLI commands directly. +/// # Panics +/// +/// Panics if the binary does not exist. pub fn ensure_binaries_built() -> PathBuf { CLI_BINARY_PATH .get_or_init(|| { - // Navigate from crates/guard/ to workspace root - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_dir - .parent() // crates/ - .and_then(|p| p.parent()) // workspace root - .expect("Failed to find workspace root"); - - // Determine target directory - let target_dir = env::var("CARGO_TARGET_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| workspace_root.join("target")); - - // Determine profile - let profile = if cfg!(debug_assertions) { "debug" } else { "release" }; - - // Build both binaries (standalone needed by CLI's start command) - for pkg in ["spacetimedb-standalone", "spacetimedb-cli"] { - let mut args = vec!["build", "-p", pkg]; - if profile == "release" { - args.push("--release"); - } - - // Clear cargo-provided env vars to avoid unnecessary rebuilds. - // When running under `cargo test`, cargo sets env vars like - // CARGO_ENCODED_RUSTFLAGS that differ from a normal build, - // causing the child cargo to think it needs to recompile. - let mut cmd = Command::new("cargo"); - cmd.args(&args).current_dir(workspace_root); - for (key, _) in env::vars() { - if key.starts_with("CARGO") && key != "CARGO_HOME" && key != "CARGO_TARGET_DIR" { - cmd.env_remove(&key); - } - } - - let status = cmd - .status() - .unwrap_or_else(|e| panic!("Failed to build {}: {}", pkg, e)); - - assert!(status.success(), "Building {} failed", pkg); + let cli_path = cli_binary_path(); + + if !cli_path.exists() { + panic!( + "\n\ + ========================================================================\n\ + ERROR: CLI binary not found at {}\n\ + \n\ + Smoketests require pre-built binaries. Run:\n\ + \n\ + cargo smoketest\n\ + \n\ + Or build manually:\n\ + \n\ + cargo build -p spacetimedb-cli -p spacetimedb-standalone\n\ + ========================================================================\n", + cli_path.display() + ); } - // Return path to CLI binary - let cli_name = if cfg!(windows) { - "spacetimedb-cli.exe" - } else { - "spacetimedb-cli" - }; - let cli_path = target_dir.join(profile).join(cli_name); - - assert!(cli_path.exists(), "CLI binary not found at {}", cli_path.display()); - cli_path }) .clone() @@ -90,6 +102,8 @@ pub struct SpacetimeDbGuard { /// Owns the temporary data directory (if created by spawn_in_temp_data_dir). /// When this is Some, dropping the guard will clean up the temp dir. _data_dir_handle: Option, + /// Reader thread handles for stdout/stderr - joined on drop to prevent leaks. + reader_threads: Vec>, } // Remove all Cargo-provided env vars from a child process. These are set by the fact that we're running in a cargo @@ -155,17 +169,39 @@ impl SpacetimeDbGuard { data_dir: PathBuf, _data_dir_handle: Option, ) -> Self { + let spawn_id = next_spawn_id(); + if use_installed_cli { // Use the installed CLI (rare case, mainly for spawn_in_temp_data_dir_use_cli) + eprintln!("[SPAWN-{:03}] START (installed CLI) data_dir={:?}", spawn_id, data_dir); + let address = "127.0.0.1:0".to_string(); let data_dir_str = data_dir.display().to_string(); let args = ["start", "--data-dir", &data_dir_str, "--listen-addr", &address]; let cmd = Command::new("spacetime"); - let (child, logs) = Self::spawn_child(cmd, env!("CARGO_MANIFEST_DIR"), &args); + let (child, logs, reader_threads) = + Self::spawn_child(cmd, env!("CARGO_MANIFEST_DIR"), &args, spawn_id); + + eprintln!("[SPAWN-{:03}] Waiting for listen address", spawn_id); + let listen_addr = wait_for_listen_addr(&logs, Duration::from_secs(10), spawn_id).unwrap_or_else(|| { + let buf = logs.lock().unwrap(); + eprintln!("[SPAWN-{:03}] TIMEOUT after 10s", spawn_id); + eprintln!( + "[SPAWN-{:03}] Captured {} bytes, {} lines", + spawn_id, + buf.len(), + buf.lines().count() + ); + eprintln!( + "[SPAWN-{:03}] Contains 'Starting SpacetimeDB': {}", + spawn_id, + buf.contains("Starting SpacetimeDB") + ); + panic!("Timed out waiting for SpacetimeDB to report listen address") + }); + eprintln!("[SPAWN-{:03}] Got listen_addr={}", spawn_id, listen_addr); - let listen_addr = wait_for_listen_addr(&logs, Duration::from_secs(10)) - .unwrap_or_else(|| panic!("Timed out waiting for SpacetimeDB to report listen address")); let host_url = format!("http://{}", listen_addr); let guard = SpacetimeDbGuard { child, @@ -174,12 +210,14 @@ impl SpacetimeDbGuard { pg_port, data_dir, _data_dir_handle, + reader_threads, }; guard.wait_until_http_ready(Duration::from_secs(10)); + eprintln!("[SPAWN-{:03}] HTTP ready", spawn_id); guard } else { // Use the built CLI (common case) - let (child, logs, host_url) = Self::spawn_server(&data_dir, pg_port); + let (child, logs, host_url, reader_threads) = Self::spawn_server(&data_dir, pg_port, spawn_id); SpacetimeDbGuard { child, host_url, @@ -187,6 +225,7 @@ impl SpacetimeDbGuard { pg_port, data_dir, _data_dir_handle, + reader_threads, } } } @@ -204,41 +243,80 @@ impl SpacetimeDbGuard { /// This stops the current server process and starts a new one /// with the same data directory, preserving all data. pub fn restart(&mut self) { + let spawn_id = next_spawn_id(); + let old_pid = self.child.id(); + eprintln!("[RESTART-{:03}] Starting restart, old pid={}", spawn_id, old_pid); + self.stop(); + eprintln!("[RESTART-{:03}] Old process stopped, sleeping 100ms", spawn_id); - let (child, logs, host_url) = Self::spawn_server(&self.data_dir, self.pg_port); + // Brief pause to ensure system resources are fully released + sleep(Duration::from_millis(100)); + + eprintln!("[RESTART-{:03}] Spawning new server", spawn_id); + let (child, logs, host_url, reader_threads) = + Self::spawn_server(&self.data_dir, self.pg_port, spawn_id); + eprintln!( + "[RESTART-{:03}] New server ready, pid={}, url={}", + spawn_id, + child.id(), + host_url + ); self.child = child; self.logs = logs; self.host_url = host_url; + self.reader_threads = reader_threads; } /// Kills the current server process and waits for it to exit. fn kill_process(&mut self) { + let pid = self.child.id(); + eprintln!("[KILL] Killing process tree for pid={}", pid); + // Kill the process tree to ensure all child processes are terminated. // On Windows, child.kill() only kills the direct child (spacetimedb-cli), // leaving spacetimedb-standalone running as an orphan. #[cfg(windows)] { - let pid = self.child.id(); - let _ = Command::new("taskkill") + let status = Command::new("taskkill") .args(["/F", "/T", "/PID", &pid.to_string()]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); + eprintln!("[KILL] taskkill result for pid={}: {:?}", pid, status); } #[cfg(not(windows))] { - let _ = self.child.kill(); + let result = self.child.kill(); + eprintln!("[KILL] kill result for pid={}: {:?}", pid, result); } - let _ = self.child.wait(); + let wait_result = self.child.wait(); + eprintln!("[KILL] wait() result for pid={}: {:?}", pid, wait_result); + + // Join reader threads to prevent leaks. + // The threads will exit naturally once the process is killed and pipes close. + let threads = std::mem::take(&mut self.reader_threads); + for handle in threads { + let _ = handle.join(); + } + eprintln!("[KILL] Reader threads joined for pid={}", pid); } /// Spawns a new server process with the given data directory. - /// Returns (child, logs, host_url). - fn spawn_server(data_dir: &Path, pg_port: Option) -> (Child, Arc>, String) { + /// Returns (child, logs, host_url, reader_threads). + fn spawn_server( + data_dir: &Path, + pg_port: Option, + spawn_id: u64, + ) -> (Child, Arc>, String, Vec>) { + eprintln!( + "[SPAWN-{:03}] START data_dir={:?}, pg_port={:?}", + spawn_id, data_dir, pg_port + ); + let data_dir_str = data_dir.display().to_string(); let pg_port_str = pg_port.map(|p| p.to_string()); @@ -256,22 +334,47 @@ impl SpacetimeDbGuard { .and_then(|p| p.parent()) .expect("Failed to find workspace root"); + eprintln!("[SPAWN-{:03}] Spawning child process", spawn_id); let cmd = Command::new(&cli_path); - let (child, logs) = Self::spawn_child(cmd, workspace_root.to_str().unwrap(), &args); + let (child, logs, reader_threads) = + Self::spawn_child(cmd, workspace_root.to_str().unwrap(), &args, spawn_id); + eprintln!("[SPAWN-{:03}] Child spawned pid={}", spawn_id, child.id()); // Wait for the server to be ready - let listen_addr = wait_for_listen_addr(&logs, Duration::from_secs(10)) - .unwrap_or_else(|| panic!("Timed out waiting for SpacetimeDB to report listen address")); + eprintln!("[SPAWN-{:03}] Waiting for listen address", spawn_id); + let listen_addr = wait_for_listen_addr(&logs, Duration::from_secs(10), spawn_id).unwrap_or_else(|| { + // Dump diagnostic info on failure + let buf = logs.lock().unwrap(); + eprintln!("[SPAWN-{:03}] TIMEOUT after 10s", spawn_id); + eprintln!( + "[SPAWN-{:03}] Captured {} bytes, {} lines", + spawn_id, + buf.len(), + buf.lines().count() + ); + eprintln!( + "[SPAWN-{:03}] Contains 'Starting SpacetimeDB': {}", + spawn_id, + buf.contains("Starting SpacetimeDB") + ); + // Check if process is still running + drop(buf); // Release lock before try_wait + panic!("Timed out waiting for SpacetimeDB to report listen address") + }); + eprintln!("[SPAWN-{:03}] Got listen_addr={}", spawn_id, listen_addr); + let host_url = format!("http://{}", listen_addr); // Wait until HTTP is ready + eprintln!("[SPAWN-{:03}] Waiting for HTTP ready", spawn_id); let client = Client::new(); let deadline = Instant::now() + Duration::from_secs(10); while Instant::now() < deadline { let url = format!("{}/v1/ping", host_url); if let Ok(resp) = client.get(&url).send() { if resp.status().is_success() { - return (child, logs, host_url); + eprintln!("[SPAWN-{:03}] HTTP ready at {}", spawn_id, host_url); + return (child, logs, host_url, reader_threads); } } sleep(Duration::from_millis(50)); @@ -279,7 +382,14 @@ impl SpacetimeDbGuard { panic!("Timed out waiting for SpacetimeDB HTTP /v1/ping at {}", host_url); } - fn spawn_child(mut cmd: Command, workspace_dir: &str, args: &[&str]) -> (Child, Arc>) { + fn spawn_child( + mut cmd: Command, + workspace_dir: &str, + args: &[&str], + spawn_id: u64, + ) -> (Child, Arc>, Vec>) { + eprintln!("[SPAWN-{:03}] spawn_child: about to spawn", spawn_id); + let mut child = cmd .args(args) .current_dir(workspace_dir) @@ -288,37 +398,66 @@ impl SpacetimeDbGuard { .spawn() .expect("failed to spawn spacetimedb-cli"); + let pid = child.id(); + eprintln!("[SPAWN-{:03}] spawn_child: spawned pid={}", spawn_id, pid); + let logs = Arc::new(Mutex::new(String::new())); + let mut reader_threads = Vec::new(); - // Attach stdout logger + // Attach stdout logger with diagnostic logging if let Some(stdout) = child.stdout.take() { let logs_clone = logs.clone(); - thread::spawn(move || { + let handle = thread::spawn(move || { + eprintln!("[READER-{:03}] stdout reader started for pid={}", spawn_id, pid); let reader = BufReader::new(stdout); + let mut line_count = 0; for line in reader.lines().map_while(Result::ok) { + line_count += 1; + // Log the first few lines and any line containing the listen address + if line_count <= 5 || line.contains("Starting SpacetimeDB") { + eprintln!("[READER-{:03}] stdout line {}: {:.100}", spawn_id, line_count, line); + } let mut buf = logs_clone.lock().unwrap(); buf.push_str("[STDOUT] "); buf.push_str(&line); buf.push('\n'); } + eprintln!( + "[READER-{:03}] stdout reader ended, {} lines total", + spawn_id, line_count + ); }); + reader_threads.push(handle); } - // Attach stderr logger + // Attach stderr logger with diagnostic logging if let Some(stderr) = child.stderr.take() { let logs_clone = logs.clone(); - thread::spawn(move || { + let handle = thread::spawn(move || { + eprintln!("[READER-{:03}] stderr reader started for pid={}", spawn_id, pid); let reader = BufReader::new(stderr); + let mut line_count = 0; for line in reader.lines().map_while(Result::ok) { + line_count += 1; + // Log the first few lines and any errors + if line_count <= 5 || line.contains("error") || line.contains("Error") { + eprintln!("[READER-{:03}] stderr line {}: {:.100}", spawn_id, line_count, line); + } let mut buf = logs_clone.lock().unwrap(); buf.push_str("[STDERR] "); buf.push_str(&line); buf.push('\n'); } + eprintln!( + "[READER-{:03}] stderr reader ended, {} lines total", + spawn_id, line_count + ); }); + reader_threads.push(handle); } - (child, logs) + eprintln!("[SPAWN-{:03}] spawn_child: readers attached", spawn_id); + (child, logs, reader_threads) } fn wait_until_http_ready(&self, timeout: Duration) { @@ -342,30 +481,61 @@ impl SpacetimeDbGuard { /// Wait for a line like: /// "... Starting SpacetimeDB listening on 0.0.0.0:24326" -fn wait_for_listen_addr(logs: &Arc>, timeout: Duration) -> Option { - let deadline = Instant::now() + timeout; - let mut cursor = 0usize; +fn wait_for_listen_addr(logs: &Arc>, timeout: Duration, spawn_id: u64) -> Option { + let start = Instant::now(); + let deadline = start + timeout; + let mut last_len = 0; + let mut last_report = Instant::now(); while Instant::now() < deadline { - let (new_text, new_len) = { - let buf = logs.lock().unwrap(); - if cursor >= buf.len() { - (String::new(), buf.len()) - } else { - (buf[cursor..].to_string(), buf.len()) - } - }; - cursor = new_len; + // Always search the entire log buffer to avoid missing lines that + // might be split across multiple reader iterations. + let buf = logs.lock().unwrap().clone(); - for line in new_text.lines() { + for line in buf.lines() { if let Some(addr) = parse_listen_addr_from_line(line) { + eprintln!("[SPAWN-{:03}] Found listen addr after {:?}", spawn_id, start.elapsed()); return Some(addr); } } + // Progress report every 2 seconds + let current_len = buf.len(); + if last_report.elapsed() > Duration::from_secs(2) { + let delta = current_len.saturating_sub(last_len); + eprintln!( + "[SPAWN-{:03}] Waiting: {} bytes (+{}), {} lines, {:?} elapsed", + spawn_id, + current_len, + delta, + buf.lines().count(), + start.elapsed() + ); + last_len = current_len; + last_report = Instant::now(); + } + sleep(Duration::from_millis(25)); } + // Debug output on timeout + let buf = logs.lock().unwrap().clone(); + eprintln!( + "[SPAWN-{:03}] wait_for_listen_addr TIMEOUT: {} bytes, {} lines, elapsed {:?}", + spawn_id, + buf.len(), + buf.lines().count(), + start.elapsed() + ); + eprintln!( + "[SPAWN-{:03}] Contains 'Starting SpacetimeDB': {}", + spawn_id, + buf.contains("Starting SpacetimeDB") + ); + // Show first 500 chars + let preview: String = buf.chars().take(500).collect(); + eprintln!("[SPAWN-{:03}] First 500 chars: {:?}", spawn_id, preview); + None } diff --git a/crates/smoketests/Cargo.toml b/crates/smoketests/Cargo.toml index 1aa3d3ba8a6..8bb517ec567 100644 --- a/crates/smoketests/Cargo.toml +++ b/crates/smoketests/Cargo.toml @@ -15,6 +15,8 @@ anyhow.workspace = true [dev-dependencies] cargo_metadata.workspace = true +assert_cmd = "2" +predicates = "3" [lints] workspace = true diff --git a/crates/smoketests/DEVELOP.md b/crates/smoketests/DEVELOP.md index 0fe3d6e7eb3..4888fa05829 100644 --- a/crates/smoketests/DEVELOP.md +++ b/crates/smoketests/DEVELOP.md @@ -2,33 +2,62 @@ ## Running Tests -### Recommended: cargo-nextest +### Recommended: cargo smoketest -For faster test execution, use [cargo-nextest](https://nexte.st/): +```bash +cargo smoketest +``` + +This command: +1. Builds `spacetimedb-cli` and `spacetimedb-standalone` binaries +2. Runs all smoketests in parallel using nextest (or cargo test if nextest isn't installed) + +To run specific tests: +```bash +cargo smoketest test_sql_format +cargo smoketest "cli::" # Run all CLI tests +``` + +### WARNING: Stale Binary Risk + +**Smoketests use pre-built binaries and DO NOT automatically rebuild them.** + +If you modify code in `spacetimedb-cli`, `spacetimedb-standalone`, or their dependencies, +you MUST rebuild before running tests: ```bash -# Install (one-time) -cargo install cargo-nextest --locked +# Option 1: Use cargo smoketest (always rebuilds first) +cargo smoketest -# Run all smoketests +# Option 2: Manually rebuild, then run tests directly +cargo build -p spacetimedb-cli -p spacetimedb-standalone cargo nextest run -p spacetimedb-smoketests +``` + +**If you run `cargo nextest run` or `cargo test` directly without rebuilding, +you may be testing against OLD binaries.** This can cause confusing test failures +or, worse, tests that pass when they shouldn't. -# Run a specific test -cargo nextest run -p spacetimedb-smoketests test_sql_format +To check which binary you're testing against: +```bash +ls -la target/debug/spacetimedb-cli* # Check modification time ``` -**Why nextest?** Standard `cargo test` compiles each test file in `tests/` as a separate binary and runs them sequentially. Nextest runs all test binaries in parallel, reducing total runtime by ~40% (160s vs 265s for 25 tests). +### Why This Design? + +Running `cargo build` from inside parallel tests causes race conditions on Windows +where multiple processes try to replace running executables ("Access denied" errors). +Pre-building avoids this entirely. ### Alternative: cargo test -Standard `cargo test` also works: +Standard `cargo test` also works, but you must rebuild first: ```bash +cargo build -p spacetimedb-cli -p spacetimedb-standalone cargo test -p spacetimedb-smoketests ``` -Tests within each file run in parallel, but files run sequentially. - ## Test Performance Each test takes ~15-20s due to: diff --git a/crates/smoketests/TEST_DURATIONS.md b/crates/smoketests/TEST_DURATIONS.md new file mode 100644 index 00000000000..e99050b0c16 --- /dev/null +++ b/crates/smoketests/TEST_DURATIONS.md @@ -0,0 +1,92 @@ +# Smoketest Duration Report + +Generated: 2026-01-25 + +**Total: 121.0s** (84 tests, 16 parallel, release mode) + +| Duration | Status | Test | +|----------|--------|------| +| 77.393s | PASS | `smoketests::quickstart::test_quickstart_rust` | +| 62.893s | PASS | `smoketests::cli::publish::cli_can_publish_no_conflict_with_delete_data_flag` | +| 62.865s | PASS | `smoketests::cli::publish::cli_can_publish_no_conflict_does_not_delete_data` | +| 59.912s | PASS | `smoketests::cli::publish::cli_cannot_publish_automigration_change_with_on_conflict_without_yes_break_clients` | +| 55.821s | PASS | `smoketests::quickstart::test_quickstart_typescript` | +| 55.556s | PASS | `smoketests::cli::publish::cli_cannot_publish_breaking_change_without_flag` | +| 53.045s | PASS | `smoketests::cli::publish::cli_can_publish_with_automigration_change` | +| 52.949s | PASS | `smoketests::cli::publish::cli_cannot_publish_automigration_change_without_yes_break_clients` | +| 50.787s | FAIL | `smoketests::quickstart::test_quickstart_csharp` | +| 49.373s | PASS | `smoketests::cli::publish::cli_can_publish_breaking_change_with_on_conflict_flag` | +| 41.589s | PASS | `smoketests::cli::publish::cli_can_publish_automigration_change_with_on_conflict_and_yes_break_clients` | +| 36.317s | PASS | `smoketests::cli::publish::cli_can_publish_no_conflict_without_delete_data_flag` | +| 34.333s | PASS | `smoketests::cli::publish::cli_can_publish_breaking_change_with_delete_data_flag` | +| 32.920s | PASS | `smoketests::cli::publish::cli_can_publish_automigration_change_with_delete_data_always_without_yes_break_clients` | +| 32.901s | PASS | `smoketests::cli::publish::cli_can_publish_automigration_change_with_delete_data_always_and_yes_break_clients` | +| 31.295s | PASS | `smoketests::cli::publish::cli_can_publish_spacetimedb_on_disk` | +| 29.956s | PASS | `smoketests::namespaces::test_custom_ns_csharp` | +| 28.922s | PASS | `smoketests::namespaces::test_spacetimedb_ns_csharp` | +| 18.648s | PASS | `smoketests::csharp_module::test_build_csharp_module` | +| 17.463s | PASS | `smoketests::domains::test_subdomain_behavior` | +| 17.355s | PASS | `smoketests::auto_migration::test_add_table_auto_migration` | +| 16.533s | PASS | `smoketests::auto_migration::test_reject_schema_changes` | +| 15.261s | PASS | `smoketests::default_module_clippy::test_default_module_clippy_check` | +| 15.105s | PASS | `smoketests::call::test_call_many_errors` | +| 10.927s | PASS | `smoketests::permissions::test_cannot_delete_others_database` | +| 10.030s | PASS | `smoketests::fail_initial_publish::test_fail_initial_publish` | +| 7.896s | PASS | `smoketests::modules::test_module_update` | +| 6.659s | PASS | `smoketests::detect_wasm_bindgen::test_detect_wasm_bindgen` | +| 6.651s | PASS | `smoketests::delete_database::test_delete_database` | +| 6.603s | PASS | `smoketests::domains::test_set_to_existing_name` | +| 6.572s | PASS | `smoketests::restart::test_add_remove_index_after_restart` | +| 6.471s | PASS | `smoketests::restart::test_restart_auto_disconnect` | +| 5.411s | PASS | `smoketests::schedule_reducer::test_scheduled_table_subscription_repeated_reducer` | +| 5.134s | PASS | `smoketests::detect_wasm_bindgen::test_detect_getrandom` | +| 5.131s | PASS | `smoketests::pg_wire::test_failures` | +| 4.857s | PASS | `smoketests::views::test_fail_publish_namespace_collision` | +| 4.843s | PASS | `smoketests::timestamp_route::test_timestamp_route` | +| 4.796s | PASS | `smoketests::domains::test_set_name` | +| 4.630s | PASS | `smoketests::schedule_reducer::test_scheduled_table_subscription` | +| 4.476s | PASS | `smoketests::energy::test_energy_balance` | +| 3.910s | PASS | `smoketests::views::test_fail_publish_wrong_return_type` | +| 3.375s | PASS | `smoketests::schedule_reducer::test_cancel_reducer` | +| 3.375s | PASS | `smoketests::restart::test_restart_sql` | +| 3.135s | PASS | `smoketests::restart::test_restart_module` | +| 3.111s | PASS | `smoketests::rls::test_rls_rules` | +| 3.034s | PASS | `smoketests::schedule_reducer::test_volatile_nonatomic_schedule_immediate` | +| 3.015s | PASS | `smoketests::pg_wire::test_sql_format` | +| 2.745s | PASS | `smoketests::filtering::test_filtering` | +| 2.037s | PASS | `smoketests::sql::test_sql_format` | +| 1.778s | PASS | `smoketests::views::test_query_anonymous_view_reducer` | +| 1.654s | PASS | `smoketests::servers::test_edit_server` | +| 1.640s | PASS | `smoketests::confirmed_reads::test_confirmed_reads_receive_updates` | +| 1.392s | PASS | `smoketests::add_remove_index::test_add_then_remove_index` | +| 1.293s | PASS | `smoketests::auto_inc::test_autoinc_u64` | +| 1.288s | PASS | `smoketests::auto_inc::test_autoinc_i32` | +| 1.286s | PASS | `smoketests::auto_inc::test_autoinc_unique_u64` | +| 1.286s | PASS | `smoketests::auto_inc::test_autoinc_u32` | +| 1.270s | PASS | `smoketests::auto_inc::test_autoinc_unique_i64` | +| 1.224s | PASS | `smoketests::dml::test_subscribe` | +| 1.213s | PASS | `smoketests::auto_inc::test_autoinc_i64` | +| 1.192s | PASS | `smoketests::call::test_call_errors` | +| 1.151s | PASS | `smoketests::call::test_call_reducer_procedure` | +| 1.106s | PASS | `smoketests::call::test_call_empty_errors` | +| 1.093s | PASS | `smoketests::servers::test_servers` | +| 1.062s | PASS | `smoketests::confirmed_reads::test_sql_with_confirmed_reads_receives_result` | +| 1.031s | PASS | `smoketests::new_user_flow::test_new_user_flow` | +| 1.000s | PASS | `smoketests::views::test_st_view_tables` | +| 0.809s | PASS | `smoketests::client_connection_errors::test_client_disconnected_error_still_deletes_st_client` | +| 0.724s | PASS | `smoketests::views::test_http_sql_views` | +| 0.630s | PASS | `smoketests::permissions::test_lifecycle_reducers_cant_be_called` | +| 0.623s | PASS | `smoketests::panic::test_reducer_error_message` | +| 0.622s | PASS | `smoketests::permissions::test_private_table` | +| 0.598s | PASS | `smoketests::module_nested_op::test_module_nested_op` | +| 0.593s | PASS | `smoketests::modules::test_upload_module` | +| 0.537s | PASS | `smoketests::panic::test_panic` | +| 0.523s | PASS | `smoketests::client_connection_errors::test_client_connected_error_rejects_connection` | +| 0.514s | PASS | `smoketests::describe::test_describe` | +| 0.402s | PASS | `smoketests::connect_disconnect_from_cli::test_conn_disconn` | +| 0.215s | PASS | `smoketests::cli::server::cli_can_ping_spacetimedb_on_disk` | +| 0.102s | PASS | `smoketests::cli::dev::cli_init_with_template_creates_project` | +| 0.080s | PASS | `smoketests::create_project::test_create_project` | +| 0.074s | PASS | `smoketests::cli::dev::cli_dev_help_shows_template_option` | +| 0.067s | PASS | `smoketests::cli::dev::cli_dev_accepts_short_template_flag` | +| 0.031s | PASS | `smoketests::cli::dev::cli_dev_accepts_template_flag` | diff --git a/crates/smoketests/modules/Cargo.lock b/crates/smoketests/modules/Cargo.lock new file mode 100644 index 00000000000..5c226fd7542 --- /dev/null +++ b/crates/smoketests/modules/Cargo.lock @@ -0,0 +1,1018 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "approx" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0e60b75072ecd4168020818c0107f2857bb6c4e64252d8d3983f6263b40a5c3" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "num-traits", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "decorum" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "281759d3c8a14f5c3f0c49363be56810fcd7f910422f97f2db850c2920fde5cf" +dependencies = [ + "approx", + "num-traits", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ethnum" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +dependencies = [ + "serde", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "second-stack" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4904c83c6e51f1b9b08bfa5a86f35a51798e8307186e6f5513852210a219c0bb" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smoketest-module-add-remove-index" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-add-remove-index-indexed" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-autoinc-basic-i32" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-autoinc-basic-i64" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-autoinc-basic-u32" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-autoinc-basic-u64" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-autoinc-unique-i64" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-autoinc-unique-u64" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-call-empty" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-call-reducer-procedure" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-client-connection-disconnect-panic" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-client-connection-reject" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-confirmed-reads" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-connect-disconnect" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-delete-database" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-describe" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-dml" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-fail-initial-publish-fixed" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-filtering" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-module-nested-op" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-modules-add-table" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-modules-basic" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-namespaces" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-new-user-flow" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-panic" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-panic-error" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-permissions-lifecycle" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-permissions-private" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-pg-wire" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-restart-connected-client" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-restart-person" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-rls" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-schedule-cancel" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-schedule-subscribe" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-schedule-volatile" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-sql-format" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-views-basic" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-views-sql" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "spacetimedb" +version = "1.11.3" +dependencies = [ + "anyhow", + "bytemuck", + "bytes", + "derive_more", + "getrandom 0.2.17", + "http", + "log", + "rand 0.8.5", + "scoped-tls", + "serde_json", + "spacetimedb-bindings-macro", + "spacetimedb-bindings-sys", + "spacetimedb-lib", + "spacetimedb-primitives", + "spacetimedb-query-builder", +] + +[[package]] +name = "spacetimedb-bindings-macro" +version = "1.11.3" +dependencies = [ + "heck 0.4.1", + "humantime", + "proc-macro2", + "quote", + "spacetimedb-primitives", + "syn", +] + +[[package]] +name = "spacetimedb-bindings-sys" +version = "1.11.3" +dependencies = [ + "spacetimedb-primitives", +] + +[[package]] +name = "spacetimedb-lib" +version = "1.11.3" +dependencies = [ + "anyhow", + "bitflags", + "blake3", + "chrono", + "derive_more", + "enum-as-inner", + "hex", + "itertools", + "log", + "spacetimedb-bindings-macro", + "spacetimedb-primitives", + "spacetimedb-sats", + "thiserror", +] + +[[package]] +name = "spacetimedb-primitives" +version = "1.11.3" +dependencies = [ + "bitflags", + "either", + "enum-as-inner", + "itertools", + "nohash-hasher", +] + +[[package]] +name = "spacetimedb-query-builder" +version = "1.11.3" +dependencies = [ + "spacetimedb-lib", +] + +[[package]] +name = "spacetimedb-sats" +version = "1.11.3" +dependencies = [ + "anyhow", + "arrayvec", + "bitflags", + "bytemuck", + "bytes", + "chrono", + "decorum", + "derive_more", + "enum-as-inner", + "ethnum", + "hex", + "itertools", + "rand 0.9.2", + "second-stack", + "sha3", + "smallvec", + "spacetimedb-bindings-macro", + "spacetimedb-primitives", + "thiserror", + "uuid", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" diff --git a/crates/smoketests/modules/Cargo.toml b/crates/smoketests/modules/Cargo.toml new file mode 100644 index 00000000000..d990a309f66 --- /dev/null +++ b/crates/smoketests/modules/Cargo.toml @@ -0,0 +1,82 @@ +# Nested workspace for pre-compiled smoketest modules. +# This workspace is excluded from the root workspace and built separately +# during the smoketest warmup phase. +# +# All modules here are compiled to WASM once during warmup, then reused +# by tests without per-test compilation overhead. + +[workspace] +resolver = "3" +members = [ + # Filtering and query tests + "filtering", + "dml", + + # Views tests + "views-basic", + # "views-broken-namespace" - intentionally broken, uses runtime compilation + # "views-broken-return-type" - intentionally broken, uses runtime compilation + "views-sql", + + # Security and permissions + "rls", + "permissions-private", + "permissions-lifecycle", + + # Call/procedure tests + "call-reducer-procedure", + "call-empty", + + # SQL format tests + "sql-format", + "pg-wire", + + # Scheduled reducer tests + "schedule-cancel", + "schedule-subscribe", + "schedule-volatile", + + # Module lifecycle tests + "describe", + "modules-basic", + # "modules-breaking" - intentionally has breaking schema change, uses runtime compilation + "modules-add-table", + + # Index tests + "add-remove-index", + "add-remove-index-indexed", + + # Panic/error handling + "panic", + "panic-error", + + # Restart tests + "restart-person", + "restart-connected-client", + + # Connection tests + "connect-disconnect", + "confirmed-reads", + "delete-database", + "client-connection-reject", + "client-connection-disconnect-panic", + + # Misc tests + "namespaces", + "new-user-flow", + "module-nested-op", + # "fail-initial-publish-broken" - intentionally broken, uses runtime compilation + "fail-initial-publish-fixed", + + # Auto-increment tests (parameterized variants) + "autoinc-basic-u32", + "autoinc-basic-u64", + "autoinc-basic-i32", + "autoinc-basic-i64", + "autoinc-unique-u64", + "autoinc-unique-i64", +] + +[workspace.dependencies] +spacetimedb = { path = "../../../crates/bindings", features = ["unstable"] } +log = "0.4" diff --git a/crates/smoketests/modules/add-remove-index-indexed/Cargo.toml b/crates/smoketests/modules/add-remove-index-indexed/Cargo.toml new file mode 100644 index 00000000000..876ee4f4e14 --- /dev/null +++ b/crates/smoketests/modules/add-remove-index-indexed/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-add-remove-index-indexed" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/add-remove-index-indexed/src/lib.rs b/crates/smoketests/modules/add-remove-index-indexed/src/lib.rs new file mode 100644 index 00000000000..61ca3204d23 --- /dev/null +++ b/crates/smoketests/modules/add-remove-index-indexed/src/lib.rs @@ -0,0 +1,22 @@ +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = t1)] +pub struct T1 { #[index(btree)] id: u64 } + +#[spacetimedb::table(name = t2)] +pub struct T2 { #[index(btree)] id: u64 } + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + for id in 0..1_000 { + ctx.db.t1().insert(T1 { id }); + ctx.db.t2().insert(T2 { id }); + } +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext) { + let id = 1_001; + ctx.db.t1().insert(T1 { id }); + ctx.db.t2().insert(T2 { id }); +} diff --git a/crates/smoketests/modules/add-remove-index/Cargo.toml b/crates/smoketests/modules/add-remove-index/Cargo.toml new file mode 100644 index 00000000000..0318839bb12 --- /dev/null +++ b/crates/smoketests/modules/add-remove-index/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-add-remove-index" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/add-remove-index/src/lib.rs b/crates/smoketests/modules/add-remove-index/src/lib.rs new file mode 100644 index 00000000000..9da55e6b50d --- /dev/null +++ b/crates/smoketests/modules/add-remove-index/src/lib.rs @@ -0,0 +1,15 @@ +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = t1)] +pub struct T1 { id: u64 } + +#[spacetimedb::table(name = t2)] +pub struct T2 { id: u64 } + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + for id in 0..1_000 { + ctx.db.t1().insert(T1 { id }); + ctx.db.t2().insert(T2 { id }); + } +} diff --git a/crates/smoketests/modules/autoinc-basic-i32/Cargo.toml b/crates/smoketests/modules/autoinc-basic-i32/Cargo.toml new file mode 100644 index 00000000000..596c9b17eee --- /dev/null +++ b/crates/smoketests/modules/autoinc-basic-i32/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-autoinc-basic-i32" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/autoinc-basic-i32/src/lib.rs b/crates/smoketests/modules/autoinc-basic-i32/src/lib.rs new file mode 100644 index 00000000000..f7687c1da93 --- /dev/null +++ b/crates/smoketests/modules/autoinc-basic-i32/src/lib.rs @@ -0,0 +1,23 @@ +#![allow(non_camel_case_types)] +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person_i32)] +pub struct Person_i32 { + #[auto_inc] + key_col: i32, + name: String, +} + +#[spacetimedb::reducer] +pub fn add_i32(ctx: &ReducerContext, name: String, expected_value: i32) { + let value = ctx.db.person_i32().insert(Person_i32 { key_col: 0, name }); + assert_eq!(value.key_col, expected_value); +} + +#[spacetimedb::reducer] +pub fn say_hello_i32(ctx: &ReducerContext) { + for person in ctx.db.person_i32().iter() { + log::info!("Hello, {}:{}!", person.key_col, person.name); + } + log::info!("Hello, World!"); +} diff --git a/crates/smoketests/modules/autoinc-basic-i64/Cargo.toml b/crates/smoketests/modules/autoinc-basic-i64/Cargo.toml new file mode 100644 index 00000000000..f6273b88664 --- /dev/null +++ b/crates/smoketests/modules/autoinc-basic-i64/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-autoinc-basic-i64" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/autoinc-basic-i64/src/lib.rs b/crates/smoketests/modules/autoinc-basic-i64/src/lib.rs new file mode 100644 index 00000000000..b7fe53085ad --- /dev/null +++ b/crates/smoketests/modules/autoinc-basic-i64/src/lib.rs @@ -0,0 +1,23 @@ +#![allow(non_camel_case_types)] +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person_i64)] +pub struct Person_i64 { + #[auto_inc] + key_col: i64, + name: String, +} + +#[spacetimedb::reducer] +pub fn add_i64(ctx: &ReducerContext, name: String, expected_value: i64) { + let value = ctx.db.person_i64().insert(Person_i64 { key_col: 0, name }); + assert_eq!(value.key_col, expected_value); +} + +#[spacetimedb::reducer] +pub fn say_hello_i64(ctx: &ReducerContext) { + for person in ctx.db.person_i64().iter() { + log::info!("Hello, {}:{}!", person.key_col, person.name); + } + log::info!("Hello, World!"); +} diff --git a/crates/smoketests/modules/autoinc-basic-u32/Cargo.toml b/crates/smoketests/modules/autoinc-basic-u32/Cargo.toml new file mode 100644 index 00000000000..9846989be3e --- /dev/null +++ b/crates/smoketests/modules/autoinc-basic-u32/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-autoinc-basic-u32" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/autoinc-basic-u32/src/lib.rs b/crates/smoketests/modules/autoinc-basic-u32/src/lib.rs new file mode 100644 index 00000000000..d3968cc45d6 --- /dev/null +++ b/crates/smoketests/modules/autoinc-basic-u32/src/lib.rs @@ -0,0 +1,23 @@ +#![allow(non_camel_case_types)] +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person_u32)] +pub struct Person_u32 { + #[auto_inc] + key_col: u32, + name: String, +} + +#[spacetimedb::reducer] +pub fn add_u32(ctx: &ReducerContext, name: String, expected_value: u32) { + let value = ctx.db.person_u32().insert(Person_u32 { key_col: 0, name }); + assert_eq!(value.key_col, expected_value); +} + +#[spacetimedb::reducer] +pub fn say_hello_u32(ctx: &ReducerContext) { + for person in ctx.db.person_u32().iter() { + log::info!("Hello, {}:{}!", person.key_col, person.name); + } + log::info!("Hello, World!"); +} diff --git a/crates/smoketests/modules/autoinc-basic-u64/Cargo.toml b/crates/smoketests/modules/autoinc-basic-u64/Cargo.toml new file mode 100644 index 00000000000..4918b62ec90 --- /dev/null +++ b/crates/smoketests/modules/autoinc-basic-u64/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-autoinc-basic-u64" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/autoinc-basic-u64/src/lib.rs b/crates/smoketests/modules/autoinc-basic-u64/src/lib.rs new file mode 100644 index 00000000000..09cbd9f74a2 --- /dev/null +++ b/crates/smoketests/modules/autoinc-basic-u64/src/lib.rs @@ -0,0 +1,23 @@ +#![allow(non_camel_case_types)] +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person_u64)] +pub struct Person_u64 { + #[auto_inc] + key_col: u64, + name: String, +} + +#[spacetimedb::reducer] +pub fn add_u64(ctx: &ReducerContext, name: String, expected_value: u64) { + let value = ctx.db.person_u64().insert(Person_u64 { key_col: 0, name }); + assert_eq!(value.key_col, expected_value); +} + +#[spacetimedb::reducer] +pub fn say_hello_u64(ctx: &ReducerContext) { + for person in ctx.db.person_u64().iter() { + log::info!("Hello, {}:{}!", person.key_col, person.name); + } + log::info!("Hello, World!"); +} diff --git a/crates/smoketests/modules/autoinc-unique-i64/Cargo.toml b/crates/smoketests/modules/autoinc-unique-i64/Cargo.toml new file mode 100644 index 00000000000..621a1e11379 --- /dev/null +++ b/crates/smoketests/modules/autoinc-unique-i64/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-autoinc-unique-i64" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/autoinc-unique-i64/src/lib.rs b/crates/smoketests/modules/autoinc-unique-i64/src/lib.rs new file mode 100644 index 00000000000..8f653618fcc --- /dev/null +++ b/crates/smoketests/modules/autoinc-unique-i64/src/lib.rs @@ -0,0 +1,33 @@ +#![allow(non_camel_case_types)] +use std::error::Error; +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person_i64)] +pub struct Person_i64 { + #[auto_inc] + #[unique] + key_col: i64, + #[unique] + name: String, +} + +#[spacetimedb::reducer] +pub fn add_new_i64(ctx: &ReducerContext, name: String) -> Result<(), Box> { + let value = ctx.db.person_i64().try_insert(Person_i64 { key_col: 0, name })?; + log::info!("Assigned Value: {} -> {}", value.key_col, value.name); + Ok(()) +} + +#[spacetimedb::reducer] +pub fn update_i64(ctx: &ReducerContext, name: String, new_id: i64) { + ctx.db.person_i64().name().delete(&name); + let _value = ctx.db.person_i64().insert(Person_i64 { key_col: new_id, name }); +} + +#[spacetimedb::reducer] +pub fn say_hello_i64(ctx: &ReducerContext) { + for person in ctx.db.person_i64().iter() { + log::info!("Hello, {}:{}!", person.key_col, person.name); + } + log::info!("Hello, World!"); +} diff --git a/crates/smoketests/modules/autoinc-unique-u64/Cargo.toml b/crates/smoketests/modules/autoinc-unique-u64/Cargo.toml new file mode 100644 index 00000000000..c070a4dcd4f --- /dev/null +++ b/crates/smoketests/modules/autoinc-unique-u64/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-autoinc-unique-u64" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/autoinc-unique-u64/src/lib.rs b/crates/smoketests/modules/autoinc-unique-u64/src/lib.rs new file mode 100644 index 00000000000..9d39a1a2247 --- /dev/null +++ b/crates/smoketests/modules/autoinc-unique-u64/src/lib.rs @@ -0,0 +1,33 @@ +#![allow(non_camel_case_types)] +use std::error::Error; +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person_u64)] +pub struct Person_u64 { + #[auto_inc] + #[unique] + key_col: u64, + #[unique] + name: String, +} + +#[spacetimedb::reducer] +pub fn add_new_u64(ctx: &ReducerContext, name: String) -> Result<(), Box> { + let value = ctx.db.person_u64().try_insert(Person_u64 { key_col: 0, name })?; + log::info!("Assigned Value: {} -> {}", value.key_col, value.name); + Ok(()) +} + +#[spacetimedb::reducer] +pub fn update_u64(ctx: &ReducerContext, name: String, new_id: u64) { + ctx.db.person_u64().name().delete(&name); + let _value = ctx.db.person_u64().insert(Person_u64 { key_col: new_id, name }); +} + +#[spacetimedb::reducer] +pub fn say_hello_u64(ctx: &ReducerContext) { + for person in ctx.db.person_u64().iter() { + log::info!("Hello, {}:{}!", person.key_col, person.name); + } + log::info!("Hello, World!"); +} diff --git a/crates/smoketests/modules/call-empty/Cargo.toml b/crates/smoketests/modules/call-empty/Cargo.toml new file mode 100644 index 00000000000..0449b80a7a5 --- /dev/null +++ b/crates/smoketests/modules/call-empty/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-call-empty" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/call-empty/src/lib.rs b/crates/smoketests/modules/call-empty/src/lib.rs new file mode 100644 index 00000000000..b3fae90457d --- /dev/null +++ b/crates/smoketests/modules/call-empty/src/lib.rs @@ -0,0 +1,4 @@ +#[spacetimedb::table(name = person)] +pub struct Person { + name: String, +} diff --git a/crates/smoketests/modules/call-reducer-procedure/Cargo.toml b/crates/smoketests/modules/call-reducer-procedure/Cargo.toml new file mode 100644 index 00000000000..d87a93d41ff --- /dev/null +++ b/crates/smoketests/modules/call-reducer-procedure/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-call-reducer-procedure" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/call-reducer-procedure/src/lib.rs b/crates/smoketests/modules/call-reducer-procedure/src/lib.rs new file mode 100644 index 00000000000..da300398ff7 --- /dev/null +++ b/crates/smoketests/modules/call-reducer-procedure/src/lib.rs @@ -0,0 +1,16 @@ +use spacetimedb::{log, ProcedureContext, ReducerContext}; + +#[spacetimedb::table(name = person)] +pub struct Person { + name: String, +} + +#[spacetimedb::reducer] +pub fn say_hello(_ctx: &ReducerContext) { + log::info!("Hello, World!"); +} + +#[spacetimedb::procedure] +pub fn return_person(_ctx: &mut ProcedureContext) -> Person { + return Person { name: "World".to_owned() }; +} diff --git a/crates/smoketests/modules/client-connection-disconnect-panic/Cargo.toml b/crates/smoketests/modules/client-connection-disconnect-panic/Cargo.toml new file mode 100644 index 00000000000..b4ac3a61b2f --- /dev/null +++ b/crates/smoketests/modules/client-connection-disconnect-panic/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-client-connection-disconnect-panic" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/client-connection-disconnect-panic/src/lib.rs b/crates/smoketests/modules/client-connection-disconnect-panic/src/lib.rs new file mode 100644 index 00000000000..1409706b15b --- /dev/null +++ b/crates/smoketests/modules/client-connection-disconnect-panic/src/lib.rs @@ -0,0 +1,23 @@ +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = all_u8s, public)] +pub struct AllU8s { + number: u8, +} + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + for i in u8::MIN..=u8::MAX { + ctx.db.all_u8s().insert(AllU8s { number: i }); + } +} + +#[spacetimedb::reducer(client_connected)] +pub fn identity_connected(_ctx: &ReducerContext) -> Result<(), String> { + Ok(()) +} + +#[spacetimedb::reducer(client_disconnected)] +pub fn identity_disconnected(_ctx: &ReducerContext) { + panic!("This should be called, but the `st_client` row should still be deleted") +} diff --git a/crates/smoketests/modules/client-connection-reject/Cargo.toml b/crates/smoketests/modules/client-connection-reject/Cargo.toml new file mode 100644 index 00000000000..3fdd30aacb2 --- /dev/null +++ b/crates/smoketests/modules/client-connection-reject/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-client-connection-reject" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/client-connection-reject/src/lib.rs b/crates/smoketests/modules/client-connection-reject/src/lib.rs new file mode 100644 index 00000000000..c96118d3007 --- /dev/null +++ b/crates/smoketests/modules/client-connection-reject/src/lib.rs @@ -0,0 +1,23 @@ +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = all_u8s, public)] +pub struct AllU8s { + number: u8, +} + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + for i in u8::MIN..=u8::MAX { + ctx.db.all_u8s().insert(AllU8s { number: i }); + } +} + +#[spacetimedb::reducer(client_connected)] +pub fn identity_connected(_ctx: &ReducerContext) -> Result<(), String> { + Err("Rejecting connection from client".to_string()) +} + +#[spacetimedb::reducer(client_disconnected)] +pub fn identity_disconnected(_ctx: &ReducerContext) { + panic!("This should never be called, since we reject all connections!") +} diff --git a/crates/smoketests/modules/confirmed-reads/Cargo.toml b/crates/smoketests/modules/confirmed-reads/Cargo.toml new file mode 100644 index 00000000000..5d952b0ecb8 --- /dev/null +++ b/crates/smoketests/modules/confirmed-reads/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-confirmed-reads" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/confirmed-reads/src/lib.rs b/crates/smoketests/modules/confirmed-reads/src/lib.rs new file mode 100644 index 00000000000..93a2d37d27d --- /dev/null +++ b/crates/smoketests/modules/confirmed-reads/src/lib.rs @@ -0,0 +1,11 @@ +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = person, public)] +pub struct Person { + name: String, +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { name }); +} diff --git a/crates/smoketests/modules/connect-disconnect/Cargo.toml b/crates/smoketests/modules/connect-disconnect/Cargo.toml new file mode 100644 index 00000000000..2083bee2fcb --- /dev/null +++ b/crates/smoketests/modules/connect-disconnect/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-connect-disconnect" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/connect-disconnect/src/lib.rs b/crates/smoketests/modules/connect-disconnect/src/lib.rs new file mode 100644 index 00000000000..4ddca882501 --- /dev/null +++ b/crates/smoketests/modules/connect-disconnect/src/lib.rs @@ -0,0 +1,16 @@ +use spacetimedb::{log, ReducerContext}; + +#[spacetimedb::reducer(client_connected)] +pub fn connected(_ctx: &ReducerContext) { + log::info!("_connect called"); +} + +#[spacetimedb::reducer(client_disconnected)] +pub fn disconnected(_ctx: &ReducerContext) { + log::info!("disconnect called"); +} + +#[spacetimedb::reducer] +pub fn say_hello(_ctx: &ReducerContext) { + log::info!("Hello, World!"); +} diff --git a/crates/smoketests/modules/delete-database/Cargo.toml b/crates/smoketests/modules/delete-database/Cargo.toml new file mode 100644 index 00000000000..48a6d18dffe --- /dev/null +++ b/crates/smoketests/modules/delete-database/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-delete-database" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/delete-database/src/lib.rs b/crates/smoketests/modules/delete-database/src/lib.rs new file mode 100644 index 00000000000..fbb51b1112d --- /dev/null +++ b/crates/smoketests/modules/delete-database/src/lib.rs @@ -0,0 +1,37 @@ +use spacetimedb::{ReducerContext, Table, duration}; + +#[spacetimedb::table(name = counter, public)] +pub struct Counter { + #[primary_key] + id: u64, + val: u64 +} + +#[spacetimedb::table(name = scheduled_counter, public, scheduled(inc, at = sched_at))] +pub struct ScheduledCounter { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + sched_at: spacetimedb::ScheduleAt, +} + +#[spacetimedb::reducer] +pub fn inc(ctx: &ReducerContext, arg: ScheduledCounter) { + if let Some(mut counter) = ctx.db.counter().id().find(arg.scheduled_id) { + counter.val += 1; + ctx.db.counter().id().update(counter); + } else { + ctx.db.counter().insert(Counter { + id: arg.scheduled_id, + val: 1, + }); + } +} + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + ctx.db.scheduled_counter().insert(ScheduledCounter { + scheduled_id: 0, + sched_at: duration!(100ms).into(), + }); +} diff --git a/crates/smoketests/modules/describe/Cargo.toml b/crates/smoketests/modules/describe/Cargo.toml new file mode 100644 index 00000000000..add6b72a1bb --- /dev/null +++ b/crates/smoketests/modules/describe/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-describe" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/describe/src/lib.rs b/crates/smoketests/modules/describe/src/lib.rs new file mode 100644 index 00000000000..36c6926b612 --- /dev/null +++ b/crates/smoketests/modules/describe/src/lib.rs @@ -0,0 +1,19 @@ +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person)] +pub struct Person { + name: String, +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { name }); +} + +#[spacetimedb::reducer] +pub fn say_hello(ctx: &ReducerContext) { + for person in ctx.db.person().iter() { + log::info!("Hello, {}!", person.name); + } + log::info!("Hello, World!"); +} diff --git a/crates/smoketests/modules/dml/Cargo.toml b/crates/smoketests/modules/dml/Cargo.toml new file mode 100644 index 00000000000..525cbc25919 --- /dev/null +++ b/crates/smoketests/modules/dml/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-dml" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/dml/src/lib.rs b/crates/smoketests/modules/dml/src/lib.rs new file mode 100644 index 00000000000..d893e880cbf --- /dev/null +++ b/crates/smoketests/modules/dml/src/lib.rs @@ -0,0 +1,4 @@ +#[spacetimedb::table(name = t, public)] +pub struct T { + name: String, +} diff --git a/crates/smoketests/modules/fail-initial-publish-broken/Cargo.toml b/crates/smoketests/modules/fail-initial-publish-broken/Cargo.toml new file mode 100644 index 00000000000..0aacc78c49b --- /dev/null +++ b/crates/smoketests/modules/fail-initial-publish-broken/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-fail-initial-publish-broken" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/fail-initial-publish-broken/src/lib.rs b/crates/smoketests/modules/fail-initial-publish-broken/src/lib.rs new file mode 100644 index 00000000000..900ca9c6ade --- /dev/null +++ b/crates/smoketests/modules/fail-initial-publish-broken/src/lib.rs @@ -0,0 +1,10 @@ +use spacetimedb::{client_visibility_filter, Filter}; + +#[spacetimedb::table(name = person, public)] +pub struct Person { + name: String, +} + +#[client_visibility_filter] +// Bug: `Person` is the wrong table name, should be `person`. +const HIDE_PEOPLE_EXCEPT_ME: Filter = Filter::Sql("SELECT * FROM Person WHERE name = 'me'"); diff --git a/crates/smoketests/modules/fail-initial-publish-fixed/Cargo.toml b/crates/smoketests/modules/fail-initial-publish-fixed/Cargo.toml new file mode 100644 index 00000000000..5a832ceaf53 --- /dev/null +++ b/crates/smoketests/modules/fail-initial-publish-fixed/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-fail-initial-publish-fixed" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/fail-initial-publish-fixed/src/lib.rs b/crates/smoketests/modules/fail-initial-publish-fixed/src/lib.rs new file mode 100644 index 00000000000..0ad76a464b3 --- /dev/null +++ b/crates/smoketests/modules/fail-initial-publish-fixed/src/lib.rs @@ -0,0 +1,9 @@ +use spacetimedb::{client_visibility_filter, Filter}; + +#[spacetimedb::table(name = person, public)] +pub struct Person { + name: String, +} + +#[client_visibility_filter] +const HIDE_PEOPLE_EXCEPT_ME: Filter = Filter::Sql("SELECT * FROM person WHERE name = 'me'"); diff --git a/crates/smoketests/modules/filtering/Cargo.toml b/crates/smoketests/modules/filtering/Cargo.toml new file mode 100644 index 00000000000..8a822c3a535 --- /dev/null +++ b/crates/smoketests/modules/filtering/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-filtering" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/filtering/src/lib.rs b/crates/smoketests/modules/filtering/src/lib.rs new file mode 100644 index 00000000000..40597ee8b78 --- /dev/null +++ b/crates/smoketests/modules/filtering/src/lib.rs @@ -0,0 +1,182 @@ +use spacetimedb::{log, Identity, ReducerContext, Table}; + +#[spacetimedb::table(name = person)] +pub struct Person { + #[unique] + id: i32, + + name: String, + + #[unique] + nick: String, +} + +#[spacetimedb::reducer] +pub fn insert_person(ctx: &ReducerContext, id: i32, name: String, nick: String) { + ctx.db.person().insert(Person { id, name, nick} ); +} + +#[spacetimedb::reducer] +pub fn insert_person_twice(ctx: &ReducerContext, id: i32, name: String, nick: String) { + // We'd like to avoid an error due to a set-semantic error. + let name2 = format!("{name}2"); + ctx.db.person().insert(Person { id, name, nick: nick.clone()} ); + match ctx.db.person().try_insert(Person { id, name: name2, nick: nick.clone()}) { + Ok(_) => {}, + Err(_) => { + log::info!("UNIQUE CONSTRAINT VIOLATION ERROR: id = {}, nick = {}", id, nick) + } + } +} + +#[spacetimedb::reducer] +pub fn delete_person(ctx: &ReducerContext, id: i32) { + ctx.db.person().id().delete(&id); +} + +#[spacetimedb::reducer] +pub fn find_person(ctx: &ReducerContext, id: i32) { + match ctx.db.person().id().find(&id) { + Some(person) => log::info!("UNIQUE FOUND: id {}: {}", id, person.name), + None => log::info!("UNIQUE NOT FOUND: id {}", id), + } +} + +#[spacetimedb::reducer] +pub fn find_person_read_only(ctx: &ReducerContext, id: i32) { + let ctx = ctx.as_read_only(); + match ctx.db.person().id().find(&id) { + Some(person) => log::info!("UNIQUE FOUND: id {}: {}", id, person.name), + None => log::info!("UNIQUE NOT FOUND: id {}", id), + } +} + +#[spacetimedb::reducer] +pub fn find_person_by_name(ctx: &ReducerContext, name: String) { + for person in ctx.db.person().iter().filter(|p| p.name == name) { + log::info!("UNIQUE FOUND: id {}: {} aka {}", person.id, person.name, person.nick); + } +} + +#[spacetimedb::reducer] +pub fn find_person_by_nick(ctx: &ReducerContext, nick: String) { + match ctx.db.person().nick().find(&nick) { + Some(person) => log::info!("UNIQUE FOUND: id {}: {}", person.id, person.nick), + None => log::info!("UNIQUE NOT FOUND: nick {}", nick), + } +} + +#[spacetimedb::reducer] +pub fn find_person_by_nick_read_only(ctx: &ReducerContext, nick: String) { + let ctx = ctx.as_read_only(); + match ctx.db.person().nick().find(&nick) { + Some(person) => log::info!("UNIQUE FOUND: id {}: {}", person.id, person.nick), + None => log::info!("UNIQUE NOT FOUND: nick {}", nick), + } +} + +#[spacetimedb::table(name = nonunique_person)] +pub struct NonuniquePerson { + #[index(btree)] + id: i32, + name: String, + is_human: bool, +} + +#[spacetimedb::reducer] +pub fn insert_nonunique_person(ctx: &ReducerContext, id: i32, name: String, is_human: bool) { + ctx.db.nonunique_person().insert(NonuniquePerson { id, name, is_human } ); +} + +#[spacetimedb::reducer] +pub fn find_nonunique_person(ctx: &ReducerContext, id: i32) { + for person in ctx.db.nonunique_person().id().filter(&id) { + log::info!("NONUNIQUE FOUND: id {}: {}", id, person.name) + } +} + +#[spacetimedb::reducer] +pub fn find_nonunique_person_read_only(ctx: &ReducerContext, id: i32) { + let ctx = ctx.as_read_only(); + for person in ctx.db.nonunique_person().id().filter(&id) { + log::info!("NONUNIQUE FOUND: id {}: {}", id, person.name) + } +} + +#[spacetimedb::reducer] +pub fn find_nonunique_humans(ctx: &ReducerContext) { + for person in ctx.db.nonunique_person().iter().filter(|p| p.is_human) { + log::info!("HUMAN FOUND: id {}: {}", person.id, person.name); + } +} + +#[spacetimedb::reducer] +pub fn find_nonunique_non_humans(ctx: &ReducerContext) { + for person in ctx.db.nonunique_person().iter().filter(|p| !p.is_human) { + log::info!("NON-HUMAN FOUND: id {}: {}", person.id, person.name); + } +} + +// Ensure that [Identity] is filterable and a legal unique column. +#[spacetimedb::table(name = identified_person)] +struct IdentifiedPerson { + #[unique] + identity: Identity, + name: String, +} + +fn identify(id_number: u64) -> Identity { + let mut bytes = [0u8; 32]; + bytes[..8].clone_from_slice(&id_number.to_le_bytes()); + Identity::from_byte_array(bytes) +} + +#[spacetimedb::reducer] +fn insert_identified_person(ctx: &ReducerContext, id_number: u64, name: String) { + let identity = identify(id_number); + ctx.db.identified_person().insert(IdentifiedPerson { identity, name }); +} + +#[spacetimedb::reducer] +fn find_identified_person(ctx: &ReducerContext, id_number: u64) { + let identity = identify(id_number); + match ctx.db.identified_person().identity().find(&identity) { + Some(person) => log::info!("IDENTIFIED FOUND: {}", person.name), + None => log::info!("IDENTIFIED NOT FOUND"), + } +} + +// Ensure that indices on non-unique columns behave as we expect. +#[spacetimedb::table(name = indexed_person)] +struct IndexedPerson { + #[unique] + id: i32, + given_name: String, + #[index(btree)] + surname: String, +} + +#[spacetimedb::reducer] +fn insert_indexed_person(ctx: &ReducerContext, id: i32, given_name: String, surname: String) { + ctx.db.indexed_person().insert(IndexedPerson { id, given_name, surname }); +} + +#[spacetimedb::reducer] +fn delete_indexed_person(ctx: &ReducerContext, id: i32) { + ctx.db.indexed_person().id().delete(&id); +} + +#[spacetimedb::reducer] +fn find_indexed_people(ctx: &ReducerContext, surname: String) { + for person in ctx.db.indexed_person().surname().filter(&surname) { + log::info!("INDEXED FOUND: id {}: {}, {}", person.id, person.surname, person.given_name); + } +} + +#[spacetimedb::reducer] +fn find_indexed_people_read_only(ctx: &ReducerContext, surname: String) { + let ctx = ctx.as_read_only(); + for person in ctx.db.indexed_person().surname().filter(&surname) { + log::info!("INDEXED FOUND: id {}: {}, {}", person.id, person.surname, person.given_name); + } +} diff --git a/crates/smoketests/modules/module-nested-op/Cargo.toml b/crates/smoketests/modules/module-nested-op/Cargo.toml new file mode 100644 index 00000000000..be4b07df228 --- /dev/null +++ b/crates/smoketests/modules/module-nested-op/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-module-nested-op" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/module-nested-op/src/lib.rs b/crates/smoketests/modules/module-nested-op/src/lib.rs new file mode 100644 index 00000000000..888afb44d05 --- /dev/null +++ b/crates/smoketests/modules/module-nested-op/src/lib.rs @@ -0,0 +1,39 @@ +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = account)] +pub struct Account { + name: String, + #[unique] + id: i32, +} + +#[spacetimedb::table(name = friends)] +pub struct Friends { + friend_1: i32, + friend_2: i32, +} + +#[spacetimedb::reducer] +pub fn create_account(ctx: &ReducerContext, account_id: i32, name: String) { + ctx.db.account().insert(Account { id: account_id, name } ); +} + +#[spacetimedb::reducer] +pub fn add_friend(ctx: &ReducerContext, my_id: i32, their_id: i32) { + // Make sure our friend exists + for account in ctx.db.account().iter() { + if account.id == their_id { + ctx.db.friends().insert(Friends { friend_1: my_id, friend_2: their_id }); + return; + } + } +} + +#[spacetimedb::reducer] +pub fn say_friends(ctx: &ReducerContext) { + for friendship in ctx.db.friends().iter() { + let friend1 = ctx.db.account().id().find(&friendship.friend_1).unwrap(); + let friend2 = ctx.db.account().id().find(&friendship.friend_2).unwrap(); + log::info!("{} is friends with {}", friend1.name, friend2.name); + } +} diff --git a/crates/smoketests/modules/modules-add-table/Cargo.toml b/crates/smoketests/modules/modules-add-table/Cargo.toml new file mode 100644 index 00000000000..cc22563a9c3 --- /dev/null +++ b/crates/smoketests/modules/modules-add-table/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-modules-add-table" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/modules-add-table/src/lib.rs b/crates/smoketests/modules/modules-add-table/src/lib.rs new file mode 100644 index 00000000000..62e69ae3bc3 --- /dev/null +++ b/crates/smoketests/modules/modules-add-table/src/lib.rs @@ -0,0 +1,19 @@ +use spacetimedb::{log, ReducerContext}; + +#[spacetimedb::table(name = person)] +pub struct Person { + #[primary_key] + #[auto_inc] + id: u64, + name: String, +} + +#[spacetimedb::table(name = pets)] +pub struct Pet { + species: String, +} + +#[spacetimedb::reducer] +pub fn are_we_updated_yet(_ctx: &ReducerContext) { + log::info!("MODULE UPDATED"); +} diff --git a/crates/smoketests/modules/modules-basic/Cargo.toml b/crates/smoketests/modules/modules-basic/Cargo.toml new file mode 100644 index 00000000000..6d6bf7ae8a4 --- /dev/null +++ b/crates/smoketests/modules/modules-basic/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-modules-basic" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/modules-basic/src/lib.rs b/crates/smoketests/modules/modules-basic/src/lib.rs new file mode 100644 index 00000000000..e20a7b171bd --- /dev/null +++ b/crates/smoketests/modules/modules-basic/src/lib.rs @@ -0,0 +1,22 @@ +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person)] +pub struct Person { + #[primary_key] + #[auto_inc] + id: u64, + name: String, +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { id: 0, name }); +} + +#[spacetimedb::reducer] +pub fn say_hello(ctx: &ReducerContext) { + for person in ctx.db.person().iter() { + log::info!("Hello, {}!", person.name); + } + log::info!("Hello, World!"); +} diff --git a/crates/smoketests/modules/modules-breaking/Cargo.toml b/crates/smoketests/modules/modules-breaking/Cargo.toml new file mode 100644 index 00000000000..05b36a5e614 --- /dev/null +++ b/crates/smoketests/modules/modules-breaking/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-modules-breaking" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/modules-breaking/src/lib.rs b/crates/smoketests/modules/modules-breaking/src/lib.rs new file mode 100644 index 00000000000..d0609a457d9 --- /dev/null +++ b/crates/smoketests/modules/modules-breaking/src/lib.rs @@ -0,0 +1,8 @@ +#[spacetimedb::table(name = person)] +pub struct Person { + #[primary_key] + #[auto_inc] + id: u64, + name: String, + age: u8, +} diff --git a/crates/smoketests/modules/namespaces/Cargo.toml b/crates/smoketests/modules/namespaces/Cargo.toml new file mode 100644 index 00000000000..95895d42e7e --- /dev/null +++ b/crates/smoketests/modules/namespaces/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-namespaces" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/namespaces/src/lib.rs b/crates/smoketests/modules/namespaces/src/lib.rs new file mode 100644 index 00000000000..b55824a656c --- /dev/null +++ b/crates/smoketests/modules/namespaces/src/lib.rs @@ -0,0 +1,34 @@ +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = person, public)] +pub struct Person { + name: String, +} + +#[spacetimedb::reducer(init)] +pub fn init(_ctx: &ReducerContext) { + // Called when the module is initially published +} + +#[spacetimedb::reducer(client_connected)] +pub fn identity_connected(_ctx: &ReducerContext) { + // Called everytime a new client connects +} + +#[spacetimedb::reducer(client_disconnected)] +pub fn identity_disconnected(_ctx: &ReducerContext) { + // Called everytime a client disconnects +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { name }); +} + +#[spacetimedb::reducer] +pub fn say_hello(ctx: &ReducerContext) { + for person in ctx.db.person().iter() { + log::info!("Hello, {}!", person.name); + } + log::info!("Hello, World!"); +} diff --git a/crates/smoketests/modules/new-user-flow/Cargo.toml b/crates/smoketests/modules/new-user-flow/Cargo.toml new file mode 100644 index 00000000000..2415d582322 --- /dev/null +++ b/crates/smoketests/modules/new-user-flow/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-new-user-flow" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/new-user-flow/src/lib.rs b/crates/smoketests/modules/new-user-flow/src/lib.rs new file mode 100644 index 00000000000..44ec244e73f --- /dev/null +++ b/crates/smoketests/modules/new-user-flow/src/lib.rs @@ -0,0 +1,19 @@ +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person)] +pub struct Person { + name: String +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { name }); +} + +#[spacetimedb::reducer] +pub fn say_hello(ctx: &ReducerContext) { + for person in ctx.db.person().iter() { + log::info!("Hello, {}!", person.name); + } + log::info!("Hello, World!"); +} diff --git a/crates/smoketests/modules/panic-error/Cargo.toml b/crates/smoketests/modules/panic-error/Cargo.toml new file mode 100644 index 00000000000..2e577b299d5 --- /dev/null +++ b/crates/smoketests/modules/panic-error/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-panic-error" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/panic-error/src/lib.rs b/crates/smoketests/modules/panic-error/src/lib.rs new file mode 100644 index 00000000000..0671f6a78fc --- /dev/null +++ b/crates/smoketests/modules/panic-error/src/lib.rs @@ -0,0 +1,6 @@ +use spacetimedb::ReducerContext; + +#[spacetimedb::reducer] +fn fail(_ctx: &ReducerContext) -> Result<(), String> { + Err("oopsie :(".into()) +} diff --git a/crates/smoketests/modules/panic/Cargo.toml b/crates/smoketests/modules/panic/Cargo.toml new file mode 100644 index 00000000000..1dff6cf86b5 --- /dev/null +++ b/crates/smoketests/modules/panic/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-panic" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/panic/src/lib.rs b/crates/smoketests/modules/panic/src/lib.rs new file mode 100644 index 00000000000..6a88768378b --- /dev/null +++ b/crates/smoketests/modules/panic/src/lib.rs @@ -0,0 +1,18 @@ +use spacetimedb::{log, ReducerContext}; +use std::cell::RefCell; + +thread_local! { + static X: RefCell = RefCell::new(0); +} +#[spacetimedb::reducer] +fn first(_ctx: &ReducerContext) { + X.with(|x| { + let _x = x.borrow_mut(); + panic!() + }) +} +#[spacetimedb::reducer] +fn second(_ctx: &ReducerContext) { + X.with(|x| *x.borrow_mut()); + log::info!("Test Passed"); +} diff --git a/crates/smoketests/modules/permissions-lifecycle/Cargo.toml b/crates/smoketests/modules/permissions-lifecycle/Cargo.toml new file mode 100644 index 00000000000..af648415cec --- /dev/null +++ b/crates/smoketests/modules/permissions-lifecycle/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-permissions-lifecycle" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/permissions-lifecycle/src/lib.rs b/crates/smoketests/modules/permissions-lifecycle/src/lib.rs new file mode 100644 index 00000000000..e28175dd19a --- /dev/null +++ b/crates/smoketests/modules/permissions-lifecycle/src/lib.rs @@ -0,0 +1,8 @@ +#[spacetimedb::reducer(init)] +fn lifecycle_init(_ctx: &spacetimedb::ReducerContext) {} + +#[spacetimedb::reducer(client_connected)] +fn lifecycle_client_connected(_ctx: &spacetimedb::ReducerContext) {} + +#[spacetimedb::reducer(client_disconnected)] +fn lifecycle_client_disconnected(_ctx: &spacetimedb::ReducerContext) {} diff --git a/crates/smoketests/modules/permissions-private/Cargo.toml b/crates/smoketests/modules/permissions-private/Cargo.toml new file mode 100644 index 00000000000..96268618d7d --- /dev/null +++ b/crates/smoketests/modules/permissions-private/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-permissions-private" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/permissions-private/src/lib.rs b/crates/smoketests/modules/permissions-private/src/lib.rs new file mode 100644 index 00000000000..0c3ae36d933 --- /dev/null +++ b/crates/smoketests/modules/permissions-private/src/lib.rs @@ -0,0 +1,22 @@ +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = secret, private)] +pub struct Secret { + answer: u8, +} + +#[spacetimedb::table(name = common_knowledge, public)] +pub struct CommonKnowledge { + thing: String, +} + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + ctx.db.secret().insert(Secret { answer: 42 }); +} + +#[spacetimedb::reducer] +pub fn do_thing(ctx: &ReducerContext, thing: String) { + ctx.db.secret().insert(Secret { answer: 20 }); + ctx.db.common_knowledge().insert(CommonKnowledge { thing }); +} diff --git a/crates/smoketests/modules/pg-wire/Cargo.toml b/crates/smoketests/modules/pg-wire/Cargo.toml new file mode 100644 index 00000000000..908f0b87689 --- /dev/null +++ b/crates/smoketests/modules/pg-wire/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-pg-wire" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/pg-wire/src/lib.rs b/crates/smoketests/modules/pg-wire/src/lib.rs new file mode 100644 index 00000000000..53729e3155c --- /dev/null +++ b/crates/smoketests/modules/pg-wire/src/lib.rs @@ -0,0 +1,159 @@ +use spacetimedb::sats::{i256, u256}; +use spacetimedb::{ConnectionId, Identity, ReducerContext, SpacetimeType, Table, Timestamp, TimeDuration, Uuid}; + +#[derive(Copy, Clone)] +#[spacetimedb::table(name = t_ints, public)] +pub struct TInts { + i8: i8, + i16: i16, + i32: i32, + i64: i64, + i128: i128, + i256: i256, +} + +#[spacetimedb::table(name = t_ints_tuple, public)] +pub struct TIntsTuple { + tuple: TInts, +} + +#[derive(Copy, Clone)] +#[spacetimedb::table(name = t_uints, public)] +pub struct TUints { + u8: u8, + u16: u16, + u32: u32, + u64: u64, + u128: u128, + u256: u256, +} + +#[spacetimedb::table(name = t_uints_tuple, public)] +pub struct TUintsTuple { + tuple: TUints, +} + +#[derive(Clone)] +#[spacetimedb::table(name = t_others, public)] +pub struct TOthers { + bool: bool, + f32: f32, + f64: f64, + str: String, + bytes: Vec, + identity: Identity, + connection_id: ConnectionId, + timestamp: Timestamp, + duration: TimeDuration, + uuid: Uuid, +} + +#[spacetimedb::table(name = t_others_tuple, public)] +pub struct TOthersTuple { + tuple: TOthers +} + +#[derive(SpacetimeType, Debug, Clone, Copy)] +pub enum Action { + Inactive, + Active, +} + +#[derive(SpacetimeType, Debug, Clone, Copy)] +pub enum Color { + Gray(u8), +} + +#[derive(Copy, Clone)] +#[spacetimedb::table(name = t_simple_enum, public)] +pub struct TSimpleEnum { + id: u32, + action: Action, +} + +#[spacetimedb::table(name = t_enum, public)] +pub struct TEnum { + id: u32, + color: Color, +} + +#[spacetimedb::table(name = t_nested, public)] +pub struct TNested { + en: TEnum, + se: TSimpleEnum, + ints: TInts, +} + +#[derive(Clone)] +#[spacetimedb::table(name = t_enums)] +pub struct TEnums { + bool_opt: Option, + bool_result: Result, + action: Action, +} + +#[spacetimedb::table(name = t_enums_tuple)] +pub struct TEnumsTuple { + tuple: TEnums, +} + +#[spacetimedb::reducer] +pub fn test(ctx: &ReducerContext) { + let tuple = TInts { + i8: -25, + i16: -3224, + i32: -23443, + i64: -2344353, + i128: -234434897853, + i256: (-234434897853i128).into(), + }; + let ints = tuple; + ctx.db.t_ints().insert(tuple); + ctx.db.t_ints_tuple().insert(TIntsTuple { tuple }); + + let tuple = TUints { + u8: 105, + u16: 1050, + u32: 83892, + u64: 48937498, + u128: 4378528978889, + u256: 4378528978889u128.into(), + }; + ctx.db.t_uints().insert(tuple); + ctx.db.t_uints_tuple().insert(TUintsTuple { tuple }); + + let tuple = TOthers { + bool: true, + f32: 594806.58906, + f64: -3454353.345389043278459, + str: "This is spacetimedb".to_string(), + bytes: vec!(1, 2, 3, 4, 5, 6, 7), + identity: Identity::ONE, + connection_id: ConnectionId::ZERO, + timestamp: Timestamp::UNIX_EPOCH, + duration: TimeDuration::from_micros(1000 * 10000), + uuid: Uuid::NIL, + }; + ctx.db.t_others().insert(tuple.clone()); + ctx.db.t_others_tuple().insert(TOthersTuple { tuple }); + + ctx.db.t_simple_enum().insert(TSimpleEnum { id: 1, action: Action::Inactive }); + ctx.db.t_simple_enum().insert(TSimpleEnum { id: 2, action: Action::Active }); + + ctx.db.t_enum().insert(TEnum { id: 1, color: Color::Gray(128) }); + + ctx.db.t_nested().insert(TNested { + en: TEnum { id: 1, color: Color::Gray(128) }, + se: TSimpleEnum { id: 2, action: Action::Active }, + ints, + }); + + let tuple = TEnums { + bool_opt: Some(true), + bool_result: Ok(false), + action: Action::Active, + }; + + ctx.db.t_enums().insert(tuple.clone()); + ctx.db.t_enums_tuple().insert(TEnumsTuple { tuple }); +} diff --git a/crates/smoketests/modules/restart-connected-client/Cargo.toml b/crates/smoketests/modules/restart-connected-client/Cargo.toml new file mode 100644 index 00000000000..7e1f08e428a --- /dev/null +++ b/crates/smoketests/modules/restart-connected-client/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-restart-connected-client" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/restart-connected-client/src/lib.rs b/crates/smoketests/modules/restart-connected-client/src/lib.rs new file mode 100644 index 00000000000..1aec31ae94d --- /dev/null +++ b/crates/smoketests/modules/restart-connected-client/src/lib.rs @@ -0,0 +1,34 @@ +use log::info; +use spacetimedb::{ConnectionId, Identity, ReducerContext, Table}; + +#[spacetimedb::table(name = connected_client)] +pub struct ConnectedClient { + identity: Identity, + connection_id: ConnectionId, +} + +#[spacetimedb::reducer(client_connected)] +fn on_connect(ctx: &ReducerContext) { + ctx.db.connected_client().insert(ConnectedClient { + identity: ctx.sender, + connection_id: ctx.connection_id.expect("sender connection id unset"), + }); +} + +#[spacetimedb::reducer(client_disconnected)] +fn on_disconnect(ctx: &ReducerContext) { + let sender_identity = &ctx.sender; + let sender_connection_id = ctx.connection_id.as_ref().expect("sender connection id unset"); + let match_client = |row: &ConnectedClient| { + &row.identity == sender_identity && &row.connection_id == sender_connection_id + }; + if let Some(client) = ctx.db.connected_client().iter().find(match_client) { + ctx.db.connected_client().delete(client); + } +} + +#[spacetimedb::reducer] +fn print_num_connected(ctx: &ReducerContext) { + let n = ctx.db.connected_client().count(); + info!("CONNECTED CLIENTS: {n}") +} diff --git a/crates/smoketests/modules/restart-person/Cargo.toml b/crates/smoketests/modules/restart-person/Cargo.toml new file mode 100644 index 00000000000..7ba1520201f --- /dev/null +++ b/crates/smoketests/modules/restart-person/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-restart-person" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/restart-person/src/lib.rs b/crates/smoketests/modules/restart-person/src/lib.rs new file mode 100644 index 00000000000..daa485e9e32 --- /dev/null +++ b/crates/smoketests/modules/restart-person/src/lib.rs @@ -0,0 +1,22 @@ +use spacetimedb::{log, ReducerContext, Table}; + +#[spacetimedb::table(name = person, index(name = name_idx, btree(columns = [name])))] +pub struct Person { + #[primary_key] + #[auto_inc] + id: u32, + name: String, +} + +#[spacetimedb::reducer] +pub fn add(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { id: 0, name }); +} + +#[spacetimedb::reducer] +pub fn say_hello(ctx: &ReducerContext) { + for person in ctx.db.person().iter() { + log::info!("Hello, {}!", person.name); + } + log::info!("Hello, World!"); +} diff --git a/crates/smoketests/modules/rls/Cargo.toml b/crates/smoketests/modules/rls/Cargo.toml new file mode 100644 index 00000000000..f17e75a86b9 --- /dev/null +++ b/crates/smoketests/modules/rls/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-rls" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/rls/src/lib.rs b/crates/smoketests/modules/rls/src/lib.rs new file mode 100644 index 00000000000..a12f1852a66 --- /dev/null +++ b/crates/smoketests/modules/rls/src/lib.rs @@ -0,0 +1,17 @@ +use spacetimedb::{Identity, ReducerContext, Table}; + +#[spacetimedb::table(name = users, public)] +pub struct Users { + name: String, + identity: Identity, +} + +#[spacetimedb::client_visibility_filter] +const USER_FILTER: spacetimedb::Filter = spacetimedb::Filter::Sql( + "SELECT * FROM users WHERE identity = :sender" +); + +#[spacetimedb::reducer] +pub fn add_user(ctx: &ReducerContext, name: String) { + ctx.db.users().insert(Users { name, identity: ctx.sender }); +} diff --git a/crates/smoketests/modules/schedule-cancel/Cargo.toml b/crates/smoketests/modules/schedule-cancel/Cargo.toml new file mode 100644 index 00000000000..577575c27be --- /dev/null +++ b/crates/smoketests/modules/schedule-cancel/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-schedule-cancel" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/schedule-cancel/src/lib.rs b/crates/smoketests/modules/schedule-cancel/src/lib.rs new file mode 100644 index 00000000000..2b1395fb2be --- /dev/null +++ b/crates/smoketests/modules/schedule-cancel/src/lib.rs @@ -0,0 +1,37 @@ +use spacetimedb::{duration, log, ReducerContext, Table}; + +#[spacetimedb::reducer(init)] +fn init(ctx: &ReducerContext) { + let schedule = ctx.db.scheduled_reducer_args().insert(ScheduledReducerArgs { + num: 1, + scheduled_id: 0, + scheduled_at: duration!(100ms).into(), + }); + ctx.db.scheduled_reducer_args().scheduled_id().delete(&schedule.scheduled_id); + + let schedule = ctx.db.scheduled_reducer_args().insert(ScheduledReducerArgs { + num: 2, + scheduled_id: 0, + scheduled_at: duration!(1000ms).into(), + }); + do_cancel(ctx, schedule.scheduled_id); +} + +#[spacetimedb::table(name = scheduled_reducer_args, public, scheduled(reducer))] +pub struct ScheduledReducerArgs { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + scheduled_at: spacetimedb::ScheduleAt, + num: i32, +} + +#[spacetimedb::reducer] +fn do_cancel(ctx: &ReducerContext, schedule_id: u64) { + ctx.db.scheduled_reducer_args().scheduled_id().delete(&schedule_id); +} + +#[spacetimedb::reducer] +fn reducer(_ctx: &ReducerContext, args: ScheduledReducerArgs) { + log::info!("the reducer ran: {}", args.num); +} diff --git a/crates/smoketests/modules/schedule-subscribe/Cargo.toml b/crates/smoketests/modules/schedule-subscribe/Cargo.toml new file mode 100644 index 00000000000..526270cf6ac --- /dev/null +++ b/crates/smoketests/modules/schedule-subscribe/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-schedule-subscribe" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/schedule-subscribe/src/lib.rs b/crates/smoketests/modules/schedule-subscribe/src/lib.rs new file mode 100644 index 00000000000..d332979b0c7 --- /dev/null +++ b/crates/smoketests/modules/schedule-subscribe/src/lib.rs @@ -0,0 +1,25 @@ +use spacetimedb::{log, duration, ReducerContext, Table, Timestamp}; + +#[spacetimedb::table(name = scheduled_table, public, scheduled(my_reducer, at = sched_at))] +pub struct ScheduledTable { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + sched_at: spacetimedb::ScheduleAt, + prev: Timestamp, +} + +#[spacetimedb::reducer] +fn schedule_reducer(ctx: &ReducerContext) { + ctx.db.scheduled_table().insert(ScheduledTable { prev: Timestamp::from_micros_since_unix_epoch(0), scheduled_id: 2, sched_at: Timestamp::from_micros_since_unix_epoch(0).into(), }); +} + +#[spacetimedb::reducer] +fn schedule_repeated_reducer(ctx: &ReducerContext) { + ctx.db.scheduled_table().insert(ScheduledTable { prev: Timestamp::from_micros_since_unix_epoch(0), scheduled_id: 1, sched_at: duration!(100ms).into(), }); +} + +#[spacetimedb::reducer] +pub fn my_reducer(ctx: &ReducerContext, arg: ScheduledTable) { + log::info!("Invoked: ts={:?}, delta={:?}", ctx.timestamp, ctx.timestamp.duration_since(arg.prev)); +} diff --git a/crates/smoketests/modules/schedule-volatile/Cargo.toml b/crates/smoketests/modules/schedule-volatile/Cargo.toml new file mode 100644 index 00000000000..961b77e6a40 --- /dev/null +++ b/crates/smoketests/modules/schedule-volatile/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-schedule-volatile" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/schedule-volatile/src/lib.rs b/crates/smoketests/modules/schedule-volatile/src/lib.rs new file mode 100644 index 00000000000..edd8a8f1882 --- /dev/null +++ b/crates/smoketests/modules/schedule-volatile/src/lib.rs @@ -0,0 +1,16 @@ +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = my_table, public)] +pub struct MyTable { + x: String, +} + +#[spacetimedb::reducer] +fn do_schedule(_ctx: &ReducerContext) { + spacetimedb::volatile_nonatomic_schedule_immediate!(do_insert("hello".to_owned())); +} + +#[spacetimedb::reducer] +fn do_insert(ctx: &ReducerContext, x: String) { + ctx.db.my_table().insert(MyTable { x }); +} diff --git a/crates/smoketests/modules/sql-format/Cargo.toml b/crates/smoketests/modules/sql-format/Cargo.toml new file mode 100644 index 00000000000..ff9f2a8837c --- /dev/null +++ b/crates/smoketests/modules/sql-format/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-sql-format" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/sql-format/src/lib.rs b/crates/smoketests/modules/sql-format/src/lib.rs new file mode 100644 index 00000000000..46be87cda38 --- /dev/null +++ b/crates/smoketests/modules/sql-format/src/lib.rs @@ -0,0 +1,122 @@ +use spacetimedb::sats::{i256, u256}; +use spacetimedb::{ConnectionId, Identity, ReducerContext, Table, Timestamp, TimeDuration, SpacetimeType, Uuid}; + +#[derive(Copy, Clone)] +#[spacetimedb::table(name = t_ints)] +pub struct TInts { + i8: i8, + i16: i16, + i32: i32, + i64: i64, + i128: i128, + i256: i256, +} + +#[spacetimedb::table(name = t_ints_tuple)] +pub struct TIntsTuple { + tuple: TInts, +} + +#[derive(Copy, Clone)] +#[spacetimedb::table(name = t_uints)] +pub struct TUints { + u8: u8, + u16: u16, + u32: u32, + u64: u64, + u128: u128, + u256: u256, +} + +#[spacetimedb::table(name = t_uints_tuple)] +pub struct TUintsTuple { + tuple: TUints, +} + +#[derive(Clone)] +#[spacetimedb::table(name = t_others)] +pub struct TOthers { + bool: bool, + f32: f32, + f64: f64, + str: String, + bytes: Vec, + identity: Identity, + connection_id: ConnectionId, + timestamp: Timestamp, + duration: TimeDuration, + uuid: Uuid, +} + +#[spacetimedb::table(name = t_others_tuple)] +pub struct TOthersTuple { + tuple: TOthers +} + +#[derive(SpacetimeType, Debug, Clone, Copy)] +pub enum Action { + Inactive, + Active, +} + +#[derive(Clone)] +#[spacetimedb::table(name = t_enums)] +pub struct TEnums { + bool_opt: Option, + bool_result: Result, + action: Action, +} + +#[spacetimedb::table(name = t_enums_tuple)] +pub struct TEnumsTuple { + tuple: TEnums, +} + +#[spacetimedb::reducer] +pub fn test(ctx: &ReducerContext) { + let tuple = TInts { + i8: -25, + i16: -3224, + i32: -23443, + i64: -2344353, + i128: -234434897853, + i256: (-234434897853i128).into(), + }; + ctx.db.t_ints().insert(tuple); + ctx.db.t_ints_tuple().insert(TIntsTuple { tuple }); + + let tuple = TUints { + u8: 105, + u16: 1050, + u32: 83892, + u64: 48937498, + u128: 4378528978889, + u256: 4378528978889u128.into(), + }; + ctx.db.t_uints().insert(tuple); + ctx.db.t_uints_tuple().insert(TUintsTuple { tuple }); + + let tuple = TOthers { + bool: true, + f32: 594806.58906, + f64: -3454353.345389043278459, + str: "This is spacetimedb".to_string(), + bytes: vec!(1, 2, 3, 4, 5, 6, 7), + identity: Identity::ONE, + connection_id: ConnectionId::ZERO, + timestamp: Timestamp::UNIX_EPOCH, + duration: TimeDuration::ZERO, + uuid: Uuid::NIL, + }; + ctx.db.t_others().insert(tuple.clone()); + ctx.db.t_others_tuple().insert(TOthersTuple { tuple }); + + let tuple = TEnums { + bool_opt: Some(true), + bool_result: Ok(false), + action: Action::Active, + }; + + ctx.db.t_enums().insert(tuple.clone()); + ctx.db.t_enums_tuple().insert(TEnumsTuple { tuple }); +} diff --git a/crates/smoketests/modules/views-basic/Cargo.toml b/crates/smoketests/modules/views-basic/Cargo.toml new file mode 100644 index 00000000000..eff289cae91 --- /dev/null +++ b/crates/smoketests/modules/views-basic/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-views-basic" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/views-basic/src/lib.rs b/crates/smoketests/modules/views-basic/src/lib.rs new file mode 100644 index 00000000000..b28d064c222 --- /dev/null +++ b/crates/smoketests/modules/views-basic/src/lib.rs @@ -0,0 +1,15 @@ +use spacetimedb::ViewContext; + +#[derive(Copy, Clone)] +#[spacetimedb::table(name = player_state)] +pub struct PlayerState { + #[primary_key] + id: u64, + #[index(btree)] + level: u64, +} + +#[spacetimedb::view(name = player, public)] +pub fn player(ctx: &ViewContext) -> Option { + ctx.db.player_state().id().find(0u64) +} diff --git a/crates/smoketests/modules/views-broken-namespace/Cargo.toml b/crates/smoketests/modules/views-broken-namespace/Cargo.toml new file mode 100644 index 00000000000..f38f99a8256 --- /dev/null +++ b/crates/smoketests/modules/views-broken-namespace/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-views-broken-namespace" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/views-broken-namespace/src/lib.rs b/crates/smoketests/modules/views-broken-namespace/src/lib.rs new file mode 100644 index 00000000000..e3570c007b1 --- /dev/null +++ b/crates/smoketests/modules/views-broken-namespace/src/lib.rs @@ -0,0 +1,11 @@ +use spacetimedb::ViewContext; + +#[spacetimedb::table(name = person, public)] +pub struct Person { + name: String, +} + +#[spacetimedb::view(name = person, public)] +pub fn person(ctx: &ViewContext) -> Option { + None +} diff --git a/crates/smoketests/modules/views-broken-return-type/Cargo.toml b/crates/smoketests/modules/views-broken-return-type/Cargo.toml new file mode 100644 index 00000000000..b3eabc7190a --- /dev/null +++ b/crates/smoketests/modules/views-broken-return-type/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-views-broken-return-type" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/views-broken-return-type/src/lib.rs b/crates/smoketests/modules/views-broken-return-type/src/lib.rs new file mode 100644 index 00000000000..6870e091f02 --- /dev/null +++ b/crates/smoketests/modules/views-broken-return-type/src/lib.rs @@ -0,0 +1,13 @@ +use spacetimedb::{SpacetimeType, ViewContext}; + +#[derive(SpacetimeType)] +pub enum ABC { + A, + B, + C, +} + +#[spacetimedb::view(name = person, public)] +pub fn person(ctx: &ViewContext) -> Option { + None +} diff --git a/crates/smoketests/modules/views-sql/Cargo.toml b/crates/smoketests/modules/views-sql/Cargo.toml new file mode 100644 index 00000000000..3493d1ec626 --- /dev/null +++ b/crates/smoketests/modules/views-sql/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-views-sql" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/views-sql/src/lib.rs b/crates/smoketests/modules/views-sql/src/lib.rs new file mode 100644 index 00000000000..5c1a14923b3 --- /dev/null +++ b/crates/smoketests/modules/views-sql/src/lib.rs @@ -0,0 +1,59 @@ +use spacetimedb::{AnonymousViewContext, ReducerContext, Table, ViewContext}; + +#[derive(Copy, Clone)] +#[spacetimedb::table(name = player_state)] +#[spacetimedb::table(name = player_level)] +pub struct PlayerState { + #[primary_key] + id: u64, + #[index(btree)] + level: u64, +} + +#[derive(Clone)] +#[spacetimedb::table(name = player_info, index(name=age_level_index, btree(columns = [age, level])))] +pub struct PlayerInfo { + #[primary_key] + id: u64, + age: u64, + level: u64, +} + +#[spacetimedb::reducer] +pub fn add_player_level(ctx: &ReducerContext, id: u64, level: u64) { + ctx.db.player_level().insert(PlayerState { id, level }); +} + +#[spacetimedb::view(name = my_player_and_level, public)] +pub fn my_player_and_level(ctx: &AnonymousViewContext) -> Option { + ctx.db.player_level().id().find(0) +} + +#[spacetimedb::view(name = player_and_level, public)] +pub fn player_and_level(ctx: &AnonymousViewContext) -> Vec { + ctx.db.player_level().level().filter(2u64).collect() +} + +#[spacetimedb::view(name = player, public)] +pub fn player(ctx: &ViewContext) -> Option { + log::info!("player view called"); + ctx.db.player_state().id().find(42) +} + +#[spacetimedb::view(name = player_none, public)] +pub fn player_none(_ctx: &ViewContext) -> Option { + None +} + +#[spacetimedb::view(name = player_vec, public)] +pub fn player_vec(ctx: &ViewContext) -> Vec { + let first = ctx.db.player_state().id().find(42).unwrap(); + let second = PlayerState { id: 7, level: 3 }; + vec![first, second] +} + +#[spacetimedb::view(name = player_info_multi_index, public)] +pub fn player_info_view(ctx: &ViewContext) -> Option { + log::info!("player_info called"); + ctx.db.player_info().age_level_index().filter((25u64, 7u64)).next() +} diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index e97b7436b10..79bf7ded77a 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -4,6 +4,12 @@ //! This crate provides utilities for writing end-to-end tests that compile and publish //! SpacetimeDB modules, then exercise them via CLI commands. //! +//! # Pre-compiled Modules +//! +//! For better performance, modules can be pre-compiled during the warmup phase. +//! Use `Smoketest::builder().precompiled_module("name")` to use a pre-compiled module +//! instead of `module_code()` which compiles at runtime. +//! //! # Running Smoketests //! //! Always run smoketests using the xtask command to ensure binaries are pre-built: @@ -44,6 +50,8 @@ //! } //! ``` +pub mod modules; + use anyhow::{bail, Context, Result}; use regex::Regex; use spacetimedb_guard::{ensure_binaries_built, SpacetimeDbGuard}; @@ -227,6 +235,8 @@ pub struct Smoketest { /// Unique module name for this test instance. /// Used to avoid wasm output conflicts when tests run in parallel. module_name: String, + /// Path to pre-compiled WASM file (if using precompiled_module). + precompiled_wasm_path: Option, } /// Response from an HTTP API call. @@ -257,6 +267,7 @@ impl ApiResponse { /// Builder for creating `Smoketest` instances. pub struct SmoketestBuilder { module_code: Option, + precompiled_module: Option, bindings_features: Vec, extra_deps: String, autopublish: bool, @@ -274,6 +285,7 @@ impl SmoketestBuilder { pub fn new() -> Self { Self { module_code: None, + precompiled_module: None, bindings_features: vec!["unstable".to_string()], extra_deps: String::new(), autopublish: true, @@ -293,6 +305,28 @@ impl SmoketestBuilder { self } + /// Uses a pre-compiled module instead of runtime compilation. + /// + /// Pre-compiled modules are built during the warmup phase and stored in + /// `crates/smoketests/modules/target/`. This eliminates per-test compilation + /// overhead for static modules. + /// + /// # Example + /// + /// ```ignore + /// let test = Smoketest::builder() + /// .precompiled_module("filtering") + /// .build(); + /// ``` + /// + /// # Panics + /// + /// Panics if the module name is not found in the registry. + pub fn precompiled_module(mut self, name: &str) -> Self { + self.precompiled_module = Some(name.to_string()); + self + } + /// Sets additional features for the spacetimedb bindings dependency. pub fn bindings_features(mut self, features: &[&str]) -> Self { self.bindings_features = features.iter().map(|s| s.to_string()).collect(); @@ -332,23 +366,39 @@ impl SmoketestBuilder { ); let project_dir = tempfile::tempdir().expect("Failed to create temp project directory"); + // Check if we're using a pre-compiled module + let precompiled_wasm_path = self.precompiled_module.as_ref().map(|name| { + let path = modules::precompiled_module(name); + if !path.exists() { + panic!( + "Pre-compiled module '{}' not found at {:?}. \ + Run `cargo smoketest` to build pre-compiled modules during warmup.", + name, path + ); + } + eprintln!("[PRECOMPILED] Using pre-compiled module: {}", name); + path + }); + let project_setup_start = Instant::now(); // Generate a unique module name to avoid wasm output conflicts in parallel tests. // The format is smoketest_module_{random} which produces smoketest_module_{random}.wasm let module_name = format!("smoketest_module_{}", random_string()); - // Create project structure - fs::create_dir_all(project_dir.path().join("src")).expect("Failed to create src directory"); + // Only set up project structure if not using precompiled module + if precompiled_wasm_path.is_none() { + // Create project structure + fs::create_dir_all(project_dir.path().join("src")).expect("Failed to create src directory"); - // Write Cargo.toml with unique module name - let workspace_root = workspace_root(); - let bindings_path = workspace_root.join("crates/bindings"); - let bindings_path_str = bindings_path.display().to_string().replace('\\', "/"); - let features_str = format!("{:?}", self.bindings_features); + // Write Cargo.toml with unique module name + let workspace_root = workspace_root(); + let bindings_path = workspace_root.join("crates/bindings"); + let bindings_path_str = bindings_path.display().to_string().replace('\\', "/"); + let features_str = format!("{:?}", self.bindings_features); - let cargo_toml = format!( - r#"[package] + let cargo_toml = format!( + r#"[package] name = "{}" version = "0.1.0" edition = "2021" @@ -361,29 +411,30 @@ spacetimedb = {{ path = "{}", features = {} }} log = "0.4" {} "#, - module_name, bindings_path_str, features_str, self.extra_deps - ); - fs::write(project_dir.path().join("Cargo.toml"), cargo_toml).expect("Failed to write Cargo.toml"); + module_name, bindings_path_str, features_str, self.extra_deps + ); + fs::write(project_dir.path().join("Cargo.toml"), cargo_toml).expect("Failed to write Cargo.toml"); - // Copy rust-toolchain.toml - let toolchain_src = workspace_root.join("rust-toolchain.toml"); - if toolchain_src.exists() { - fs::copy(&toolchain_src, project_dir.path().join("rust-toolchain.toml")) - .expect("Failed to copy rust-toolchain.toml"); - } + // Copy rust-toolchain.toml + let toolchain_src = workspace_root.join("rust-toolchain.toml"); + if toolchain_src.exists() { + fs::copy(&toolchain_src, project_dir.path().join("rust-toolchain.toml")) + .expect("Failed to copy rust-toolchain.toml"); + } - // Write module code - let module_code = self.module_code.unwrap_or_else(|| { - r#"use spacetimedb::ReducerContext; + // Write module code + let module_code = self.module_code.unwrap_or_else(|| { + r#"use spacetimedb::ReducerContext; #[spacetimedb::reducer] pub fn noop(_ctx: &ReducerContext) {} "# - .to_string() - }); - fs::write(project_dir.path().join("src/lib.rs"), &module_code).expect("Failed to write lib.rs"); + .to_string() + }); + fs::write(project_dir.path().join("src/lib.rs"), &module_code).expect("Failed to write lib.rs"); - eprintln!("[TIMING] project setup: {:?}", project_setup_start.elapsed()); + eprintln!("[TIMING] project setup: {:?}", project_setup_start.elapsed()); + } let server_url = guard.host_url.clone(); let config_path = project_dir.path().join("config.toml"); @@ -394,6 +445,7 @@ pub fn noop(_ctx: &ReducerContext) {} server_url, config_path, module_name, + precompiled_wasm_path, }; if self.autopublish { @@ -536,11 +588,72 @@ impl Smoketest { } /// Writes new module code to the project. - pub fn write_module_code(&self, code: &str) -> Result<()> { + /// + /// This switches from precompiled mode to runtime compilation mode. + /// If the project structure doesn't exist (e.g., started with `precompiled_module()`), + /// it will be created on demand. + pub fn write_module_code(&mut self, code: &str) -> Result<()> { + // Clear precompiled module path so we use the source code instead + self.precompiled_wasm_path = None; + + // Create project structure on demand if it doesn't exist + // (happens when test started with precompiled_module) + let src_dir = self.project_dir.path().join("src"); + if !src_dir.exists() { + fs::create_dir_all(&src_dir).context("Failed to create src directory")?; + + // Write Cargo.toml with default settings + let workspace_root = workspace_root(); + let bindings_path = workspace_root.join("crates/bindings"); + let bindings_path_str = bindings_path.display().to_string().replace('\\', "/"); + + let cargo_toml = format!( + r#"[package] +name = "{}" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb = {{ path = "{}", features = ["unstable"] }} +log = "0.4" +"#, + self.module_name, bindings_path_str + ); + fs::write(self.project_dir.path().join("Cargo.toml"), cargo_toml) + .context("Failed to write Cargo.toml")?; + + // Copy rust-toolchain.toml + let toolchain_src = workspace_root.join("rust-toolchain.toml"); + if toolchain_src.exists() { + fs::copy(&toolchain_src, self.project_dir.path().join("rust-toolchain.toml")) + .context("Failed to copy rust-toolchain.toml")?; + } + } + fs::write(self.project_dir.path().join("src/lib.rs"), code).context("Failed to write module code")?; Ok(()) } + /// Switches to using a precompiled module. + /// + /// After calling this, subsequent `publish_module*` calls will use the + /// precompiled WASM file instead of building from source. + pub fn use_precompiled_module(&mut self, name: &str) { + let path = modules::precompiled_module(name); + if !path.exists() { + panic!( + "Pre-compiled module '{}' not found at {:?}. \ + Run `cargo smoketest` to build pre-compiled modules during warmup.", + name, path + ); + } + eprintln!("[PRECOMPILED] Switching to pre-compiled module: {}", name); + self.precompiled_wasm_path = Some(path); + } + /// Runs `spacetime build` and returns the raw output. /// /// Use this when you need to check for build failures (e.g., wasm_bindgen detection). @@ -588,37 +701,44 @@ impl Smoketest { /// Internal helper for publishing with options. fn publish_module_opts(&mut self, name: Option<&str>, clear: bool) -> Result { let start = Instant::now(); - let project_path = self.project_dir.path().to_str().unwrap().to_string(); - - // First, run spacetime build to compile the WASM module (separate from publish) - let build_start = Instant::now(); - let cli_path = ensure_binaries_built(); - let target_dir = shared_target_dir(); - - let mut build_cmd = Command::new(&cli_path); - build_cmd - .args(["build", "--project-path", &project_path]) - .current_dir(self.project_dir.path()) - .env("CARGO_TARGET_DIR", &target_dir); - - let build_output = build_cmd.output().expect("Failed to execute spacetime build"); - let build_elapsed = build_start.elapsed(); - eprintln!("[TIMING] spacetime build: {:?}", build_elapsed); - if !build_output.status.success() { - bail!( - "spacetime build failed:\nstdout: {}\nstderr: {}", - String::from_utf8_lossy(&build_output.stdout), - String::from_utf8_lossy(&build_output.stderr) - ); - } + // Determine the WASM path - either precompiled or build it + let wasm_path_str = if let Some(ref precompiled_path) = self.precompiled_wasm_path { + // Use pre-compiled WASM directly (no build needed) + eprintln!("[TIMING] spacetime build: skipped (using precompiled)"); + precompiled_path.to_str().unwrap().to_string() + } else { + // Build the WASM module from source + let project_path = self.project_dir.path().to_str().unwrap().to_string(); + let build_start = Instant::now(); + let cli_path = ensure_binaries_built(); + let target_dir = shared_target_dir(); + + let mut build_cmd = Command::new(&cli_path); + build_cmd + .args(["build", "--project-path", &project_path]) + .current_dir(self.project_dir.path()) + .env("CARGO_TARGET_DIR", &target_dir); + + let build_output = build_cmd.output().expect("Failed to execute spacetime build"); + let build_elapsed = build_start.elapsed(); + eprintln!("[TIMING] spacetime build: {:?}", build_elapsed); + + if !build_output.status.success() { + bail!( + "spacetime build failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&build_output.stdout), + String::from_utf8_lossy(&build_output.stderr) + ); + } - // Construct the wasm path using the unique module name - let wasm_filename = format!("{}.wasm", self.module_name); - let wasm_path = target_dir - .join("wasm32-unknown-unknown/release") - .join(&wasm_filename); - let wasm_path_str = wasm_path.to_str().unwrap().to_string(); + // Construct the wasm path using the unique module name + let wasm_filename = format!("{}.wasm", self.module_name); + let wasm_path = target_dir + .join("wasm32-unknown-unknown/release") + .join(&wasm_filename); + wasm_path.to_str().unwrap().to_string() + }; // Now publish with --bin-path to skip rebuild let publish_start = Instant::now(); diff --git a/crates/smoketests/src/modules.rs b/crates/smoketests/src/modules.rs new file mode 100644 index 00000000000..33eb6d94ee6 --- /dev/null +++ b/crates/smoketests/src/modules.rs @@ -0,0 +1,147 @@ +//! Registry for pre-compiled smoketest modules. +//! +//! This module provides access to WASM modules that are pre-compiled during the +//! smoketest warmup phase, eliminating per-test compilation overhead. +//! +//! Modules are built from the nested workspace at `crates/smoketests/modules/` +//! and their WASM outputs are stored in that workspace's target directory. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::OnceLock; + +use crate::workspace_root; + +/// Registry mapping module names to their pre-compiled WASM paths. +static REGISTRY: OnceLock> = OnceLock::new(); + +/// Returns the path to a pre-compiled module's WASM file. +/// +/// # Panics +/// +/// Panics if the module name is not found in the registry. This indicates +/// either a typo in the module name or that the module hasn't been added +/// to the nested workspace yet. +pub fn precompiled_module(name: &str) -> PathBuf { + let registry = REGISTRY.get_or_init(build_registry); + registry + .get(name) + .cloned() + .unwrap_or_else(|| panic!("Unknown precompiled module: '{}'. Available modules: {:?}", name, registry.keys().collect::>())) +} + +/// Returns true if pre-compiled modules are available. +/// +/// This checks if the modules workspace target directory exists and contains +/// at least one WASM file. Tests can use this to fall back to runtime +/// compilation if precompiled modules aren't available. +pub fn precompiled_modules_available() -> bool { + let target = modules_target_dir(); + target.exists() && target.join("smoketest_module_filtering.wasm").exists() +} + +/// Returns the target directory where pre-compiled WASM modules are stored. +fn modules_target_dir() -> PathBuf { + workspace_root() + .join("crates/smoketests/modules/target/wasm32-unknown-unknown/release") +} + +/// Builds the registry mapping module names to WASM paths. +fn build_registry() -> HashMap<&'static str, PathBuf> { + let target = modules_target_dir(); + + let mut reg = HashMap::new(); + + // Filtering and query tests + reg.insert("filtering", target.join("smoketest_module_filtering.wasm")); + reg.insert("dml", target.join("smoketest_module_dml.wasm")); + + // Views tests + reg.insert("views-basic", target.join("smoketest_module_views_basic.wasm")); + // views-broken-namespace and views-broken-return-type are intentionally broken, not precompiled + reg.insert("views-sql", target.join("smoketest_module_views_sql.wasm")); + + // Security and permissions + reg.insert("rls", target.join("smoketest_module_rls.wasm")); + reg.insert("permissions-private", target.join("smoketest_module_permissions_private.wasm")); + reg.insert("permissions-lifecycle", target.join("smoketest_module_permissions_lifecycle.wasm")); + + // Call/procedure tests + reg.insert("call-reducer-procedure", target.join("smoketest_module_call_reducer_procedure.wasm")); + reg.insert("call-empty", target.join("smoketest_module_call_empty.wasm")); + + // SQL format tests + reg.insert("sql-format", target.join("smoketest_module_sql_format.wasm")); + reg.insert("pg-wire", target.join("smoketest_module_pg_wire.wasm")); + + // Scheduled reducer tests + reg.insert("schedule-cancel", target.join("smoketest_module_schedule_cancel.wasm")); + reg.insert("schedule-subscribe", target.join("smoketest_module_schedule_subscribe.wasm")); + reg.insert("schedule-volatile", target.join("smoketest_module_schedule_volatile.wasm")); + + // Module lifecycle tests + reg.insert("describe", target.join("smoketest_module_describe.wasm")); + reg.insert("modules-basic", target.join("smoketest_module_modules_basic.wasm")); + // modules-breaking is intentionally broken, not precompiled + reg.insert("modules-add-table", target.join("smoketest_module_modules_add_table.wasm")); + + // Index tests + reg.insert("add-remove-index", target.join("smoketest_module_add_remove_index.wasm")); + reg.insert("add-remove-index-indexed", target.join("smoketest_module_add_remove_index_indexed.wasm")); + + // Panic/error handling + reg.insert("panic", target.join("smoketest_module_panic.wasm")); + reg.insert("panic-error", target.join("smoketest_module_panic_error.wasm")); + + // Restart tests + reg.insert("restart-person", target.join("smoketest_module_restart_person.wasm")); + reg.insert("restart-connected-client", target.join("smoketest_module_restart_connected_client.wasm")); + + // Connection tests + reg.insert("connect-disconnect", target.join("smoketest_module_connect_disconnect.wasm")); + reg.insert("confirmed-reads", target.join("smoketest_module_confirmed_reads.wasm")); + reg.insert("delete-database", target.join("smoketest_module_delete_database.wasm")); + reg.insert("client-connection-reject", target.join("smoketest_module_client_connection_reject.wasm")); + reg.insert("client-connection-disconnect-panic", target.join("smoketest_module_client_connection_disconnect_panic.wasm")); + + // Misc tests + reg.insert("namespaces", target.join("smoketest_module_namespaces.wasm")); + reg.insert("new-user-flow", target.join("smoketest_module_new_user_flow.wasm")); + reg.insert("module-nested-op", target.join("smoketest_module_module_nested_op.wasm")); + // fail-initial-publish-broken is intentionally broken, not precompiled + reg.insert("fail-initial-publish-fixed", target.join("smoketest_module_fail_initial_publish_fixed.wasm")); + + // Auto-increment tests (parameterized variants) + reg.insert("autoinc-basic-u32", target.join("smoketest_module_autoinc_basic_u32.wasm")); + reg.insert("autoinc-basic-u64", target.join("smoketest_module_autoinc_basic_u64.wasm")); + reg.insert("autoinc-basic-i32", target.join("smoketest_module_autoinc_basic_i32.wasm")); + reg.insert("autoinc-basic-i64", target.join("smoketest_module_autoinc_basic_i64.wasm")); + reg.insert("autoinc-unique-u64", target.join("smoketest_module_autoinc_unique_u64.wasm")); + reg.insert("autoinc-unique-i64", target.join("smoketest_module_autoinc_unique_i64.wasm")); + + reg +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_registry_has_entries() { + let registry = REGISTRY.get_or_init(build_registry); + assert!(!registry.is_empty(), "Registry should have entries"); + } + + #[test] + fn test_module_paths_end_with_wasm() { + let registry = REGISTRY.get_or_init(build_registry); + for (name, path) in registry.iter() { + assert!( + path.extension().map_or(false, |ext| ext == "wasm"), + "Module {} path should end with .wasm: {:?}", + name, + path + ); + } + } +} diff --git a/crates/smoketests/tests/smoketests/add_remove_index.rs b/crates/smoketests/tests/smoketests/add_remove_index.rs index 0d166d3774f..ee0e8fa609d 100644 --- a/crates/smoketests/tests/smoketests/add_remove_index.rs +++ b/crates/smoketests/tests/smoketests/add_remove_index.rs @@ -2,49 +2,6 @@ use spacetimedb_smoketests::Smoketest; -const MODULE_CODE: &str = r#" -use spacetimedb::{ReducerContext, Table}; - -#[spacetimedb::table(name = t1)] -pub struct T1 { id: u64 } - -#[spacetimedb::table(name = t2)] -pub struct T2 { id: u64 } - -#[spacetimedb::reducer(init)] -pub fn init(ctx: &ReducerContext) { - for id in 0..1_000 { - ctx.db.t1().insert(T1 { id }); - ctx.db.t2().insert(T2 { id }); - } -} -"#; - -const MODULE_CODE_INDEXED: &str = r#" -use spacetimedb::{ReducerContext, Table}; - -#[spacetimedb::table(name = t1)] -pub struct T1 { #[index(btree)] id: u64 } - -#[spacetimedb::table(name = t2)] -pub struct T2 { #[index(btree)] id: u64 } - -#[spacetimedb::reducer(init)] -pub fn init(ctx: &ReducerContext) { - for id in 0..1_000 { - ctx.db.t1().insert(T1 { id }); - ctx.db.t2().insert(T2 { id }); - } -} - -#[spacetimedb::reducer] -pub fn add(ctx: &ReducerContext) { - let id = 1_001; - ctx.db.t1().insert(T1 { id }); - ctx.db.t2().insert(T2 { id }); -} -"#; - const JOIN_QUERY: &str = "select t1.* from t1 join t2 on t1.id = t2.id where t2.id = 1001"; /// First publish without the indices, @@ -54,7 +11,7 @@ const JOIN_QUERY: &str = "select t1.* from t1 join t2 on t1.id = t2.id where t2. /// and the unindexed versions should reject subscriptions. #[test] fn test_add_then_remove_index() { - let mut test = Smoketest::builder().module_code(MODULE_CODE).autopublish(false).build(); + let mut test = Smoketest::builder().precompiled_module("add-remove-index").autopublish(false).build(); let name = format!("test-db-{}", std::process::id()); @@ -66,7 +23,7 @@ fn test_add_then_remove_index() { // Publish the indexed version. // Now we have indices, so the query should be accepted. - test.write_module_code(MODULE_CODE_INDEXED).unwrap(); + test.use_precompiled_module("add-remove-index-indexed"); test.publish_module_named(&name, false).unwrap(); // Subscription should work now (n=0 just verifies the query is accepted) @@ -82,7 +39,7 @@ fn test_add_then_remove_index() { // Publish the unindexed version again, removing the index. // The initial subscription should be rejected again. - test.write_module_code(MODULE_CODE).unwrap(); + test.use_precompiled_module("add-remove-index"); test.publish_module_named(&name, false).unwrap(); let result = test.subscribe(&[JOIN_QUERY], 0); assert!(result.is_err(), "Expected subscription to fail after removing indices"); diff --git a/crates/smoketests/tests/smoketests/auto_inc.rs b/crates/smoketests/tests/smoketests/auto_inc.rs index dce8c1ec781..d71d0ce2ba7 100644 --- a/crates/smoketests/tests/smoketests/auto_inc.rs +++ b/crates/smoketests/tests/smoketests/auto_inc.rs @@ -5,45 +5,16 @@ use spacetimedb_smoketests::Smoketest; -/// Generate module code for basic auto-increment test with a specific integer type -fn autoinc_basic_module_code(int_ty: &str) -> String { - format!( - r#" -#![allow(non_camel_case_types)] -use spacetimedb::{{log, ReducerContext, Table}}; - -#[spacetimedb::table(name = person_{int_ty})] -pub struct Person_{int_ty} {{ - #[auto_inc] - key_col: {int_ty}, - name: String, -}} - -#[spacetimedb::reducer] -pub fn add_{int_ty}(ctx: &ReducerContext, name: String, expected_value: {int_ty}) {{ - let value = ctx.db.person_{int_ty}().insert(Person_{int_ty} {{ key_col: 0, name }}); - assert_eq!(value.key_col, expected_value); -}} - -#[spacetimedb::reducer] -pub fn say_hello_{int_ty}(ctx: &ReducerContext) {{ - for person in ctx.db.person_{int_ty}().iter() {{ - log::info!("Hello, {{}}:{{}}!", person.key_col, person.name); - }} - log::info!("Hello, World!"); -}} -"# - ) -} - -fn do_test_autoinc_basic(int_ty: &str) { - let module_code = autoinc_basic_module_code(int_ty); - let test = Smoketest::builder().module_code(&module_code).build(); +#[test] +fn test_autoinc_u32() { + let test = Smoketest::builder() + .precompiled_module("autoinc-basic-u32") + .build(); - test.call(&format!("add_{}", int_ty), &[r#""Robert""#, "1"]).unwrap(); - test.call(&format!("add_{}", int_ty), &[r#""Julie""#, "2"]).unwrap(); - test.call(&format!("add_{}", int_ty), &[r#""Samantha""#, "3"]).unwrap(); - test.call(&format!("say_hello_{}", int_ty), &[]).unwrap(); + test.call("add_u32", &[r#""Robert""#, "1"]).unwrap(); + test.call("add_u32", &[r#""Julie""#, "2"]).unwrap(); + test.call("add_u32", &[r#""Samantha""#, "3"]).unwrap(); + test.call("say_hello_u32", &[]).unwrap(); let logs = test.logs(4).unwrap(); assert!( @@ -68,85 +39,128 @@ fn do_test_autoinc_basic(int_ty: &str) { ); } -#[test] -fn test_autoinc_u32() { - do_test_autoinc_basic("u32"); -} - #[test] fn test_autoinc_u64() { - do_test_autoinc_basic("u64"); + let test = Smoketest::builder() + .precompiled_module("autoinc-basic-u64") + .build(); + + test.call("add_u64", &[r#""Robert""#, "1"]).unwrap(); + test.call("add_u64", &[r#""Julie""#, "2"]).unwrap(); + test.call("add_u64", &[r#""Samantha""#, "3"]).unwrap(); + test.call("say_hello_u64", &[]).unwrap(); + + let logs = test.logs(4).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 3:Samantha!")), + "Expected 'Hello, 3:Samantha!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 2:Julie!")), + "Expected 'Hello, 2:Julie!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 1:Robert!")), + "Expected 'Hello, 1:Robert!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, World!")), + "Expected 'Hello, World!' in logs, got: {:?}", + logs + ); } #[test] fn test_autoinc_i32() { - do_test_autoinc_basic("i32"); + let test = Smoketest::builder() + .precompiled_module("autoinc-basic-i32") + .build(); + + test.call("add_i32", &[r#""Robert""#, "1"]).unwrap(); + test.call("add_i32", &[r#""Julie""#, "2"]).unwrap(); + test.call("add_i32", &[r#""Samantha""#, "3"]).unwrap(); + test.call("say_hello_i32", &[]).unwrap(); + + let logs = test.logs(4).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 3:Samantha!")), + "Expected 'Hello, 3:Samantha!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 2:Julie!")), + "Expected 'Hello, 2:Julie!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 1:Robert!")), + "Expected 'Hello, 1:Robert!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, World!")), + "Expected 'Hello, World!' in logs, got: {:?}", + logs + ); } #[test] fn test_autoinc_i64() { - do_test_autoinc_basic("i64"); -} + let test = Smoketest::builder() + .precompiled_module("autoinc-basic-i64") + .build(); + + test.call("add_i64", &[r#""Robert""#, "1"]).unwrap(); + test.call("add_i64", &[r#""Julie""#, "2"]).unwrap(); + test.call("add_i64", &[r#""Samantha""#, "3"]).unwrap(); + test.call("say_hello_i64", &[]).unwrap(); -/// Generate module code for auto-increment with unique constraint test -fn autoinc_unique_module_code(int_ty: &str) -> String { - format!( - r#" -#![allow(non_camel_case_types)] -use std::error::Error; -use spacetimedb::{{log, ReducerContext, Table}}; - -#[spacetimedb::table(name = person_{int_ty})] -pub struct Person_{int_ty} {{ - #[auto_inc] - #[unique] - key_col: {int_ty}, - #[unique] - name: String, -}} - -#[spacetimedb::reducer] -pub fn add_new_{int_ty}(ctx: &ReducerContext, name: String) -> Result<(), Box> {{ - let value = ctx.db.person_{int_ty}().try_insert(Person_{int_ty} {{ key_col: 0, name }})?; - log::info!("Assigned Value: {{}} -> {{}}", value.key_col, value.name); - Ok(()) -}} - -#[spacetimedb::reducer] -pub fn update_{int_ty}(ctx: &ReducerContext, name: String, new_id: {int_ty}) {{ - ctx.db.person_{int_ty}().name().delete(&name); - let _value = ctx.db.person_{int_ty}().insert(Person_{int_ty} {{ key_col: new_id, name }}); -}} - -#[spacetimedb::reducer] -pub fn say_hello_{int_ty}(ctx: &ReducerContext) {{ - for person in ctx.db.person_{int_ty}().iter() {{ - log::info!("Hello, {{}}:{{}}!", person.key_col, person.name); - }} - log::info!("Hello, World!"); -}} -"# - ) + let logs = test.logs(4).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 3:Samantha!")), + "Expected 'Hello, 3:Samantha!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 2:Julie!")), + "Expected 'Hello, 2:Julie!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 1:Robert!")), + "Expected 'Hello, 1:Robert!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, World!")), + "Expected 'Hello, World!' in logs, got: {:?}", + logs + ); } -fn do_test_autoinc_unique(int_ty: &str) { - let module_code = autoinc_unique_module_code(int_ty); - let test = Smoketest::builder().module_code(&module_code).build(); +#[test] +fn test_autoinc_unique_u64() { + let test = Smoketest::builder() + .precompiled_module("autoinc-unique-u64") + .build(); // Insert Robert with explicit id 2 - test.call(&format!("update_{}", int_ty), &[r#""Robert""#, "2"]).unwrap(); + test.call("update_u64", &[r#""Robert""#, "2"]).unwrap(); // Auto-inc should assign id 1 to Success - test.call(&format!("add_new_{}", int_ty), &[r#""Success""#]).unwrap(); + test.call("add_new_u64", &[r#""Success""#]).unwrap(); // Auto-inc tries to assign id 2, but Robert already has it - should fail - let result = test.call(&format!("add_new_{}", int_ty), &[r#""Failure""#]); + let result = test.call("add_new_u64", &[r#""Failure""#]); assert!( result.is_err(), "Expected add_new to fail due to unique constraint violation" ); - test.call(&format!("say_hello_{}", int_ty), &[]).unwrap(); + test.call("say_hello_u64", &[]).unwrap(); let logs = test.logs(4).unwrap(); assert!( @@ -166,12 +180,41 @@ fn do_test_autoinc_unique(int_ty: &str) { ); } -#[test] -fn test_autoinc_unique_u64() { - do_test_autoinc_unique("u64"); -} - #[test] fn test_autoinc_unique_i64() { - do_test_autoinc_unique("i64"); + let test = Smoketest::builder() + .precompiled_module("autoinc-unique-i64") + .build(); + + // Insert Robert with explicit id 2 + test.call("update_i64", &[r#""Robert""#, "2"]).unwrap(); + + // Auto-inc should assign id 1 to Success + test.call("add_new_i64", &[r#""Success""#]).unwrap(); + + // Auto-inc tries to assign id 2, but Robert already has it - should fail + let result = test.call("add_new_i64", &[r#""Failure""#]); + assert!( + result.is_err(), + "Expected add_new to fail due to unique constraint violation" + ); + + test.call("say_hello_i64", &[]).unwrap(); + + let logs = test.logs(4).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 2:Robert!")), + "Expected 'Hello, 2:Robert!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 1:Success!")), + "Expected 'Hello, 1:Success!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, World!")), + "Expected 'Hello, World!' in logs, got: {:?}", + logs + ); } diff --git a/crates/smoketests/tests/smoketests/call.rs b/crates/smoketests/tests/smoketests/call.rs index 8ba7c717cda..42729938547 100644 --- a/crates/smoketests/tests/smoketests/call.rs +++ b/crates/smoketests/tests/smoketests/call.rs @@ -2,30 +2,11 @@ use spacetimedb_smoketests::Smoketest; -const CALL_REDUCER_PROCEDURE_MODULE_CODE: &str = r#" -use spacetimedb::{log, ProcedureContext, ReducerContext, Table}; - -#[spacetimedb::table(name = person)] -pub struct Person { - name: String, -} - -#[spacetimedb::reducer] -pub fn say_hello(_ctx: &ReducerContext) { - log::info!("Hello, World!"); -} - -#[spacetimedb::procedure] -pub fn return_person(_ctx: &mut ProcedureContext) -> Person { - return Person { name: "World".to_owned() }; -} -"#; - /// Check calling a reducer (no return) and procedure (return) #[test] fn test_call_reducer_procedure() { let test = Smoketest::builder() - .module_code(CALL_REDUCER_PROCEDURE_MODULE_CODE) + .precompiled_module("call-reducer-procedure") .build(); // Reducer returns empty @@ -41,7 +22,7 @@ fn test_call_reducer_procedure() { #[test] fn test_call_errors() { let test = Smoketest::builder() - .module_code(CALL_REDUCER_PROCEDURE_MODULE_CODE) + .precompiled_module("call-reducer-procedure") .build(); let identity = test.database_identity.as_ref().unwrap(); @@ -127,19 +108,10 @@ A procedure with a similar name exists: `return_person`" ); } -const CALL_EMPTY_MODULE_CODE: &str = r#" -use spacetimedb::{log, ReducerContext, Table}; - -#[spacetimedb::table(name = person)] -pub struct Person { - name: String, -} -"#; - /// Check calling into a database with no reducers/procedures raises error #[test] fn test_call_empty_errors() { - let test = Smoketest::builder().module_code(CALL_EMPTY_MODULE_CODE).build(); + let test = Smoketest::builder().precompiled_module("call-empty").build(); let identity = test.database_identity.as_ref().unwrap(); diff --git a/crates/smoketests/tests/smoketests/client_connection_errors.rs b/crates/smoketests/tests/smoketests/client_connection_errors.rs index 906d4569629..62cc1e896c3 100644 --- a/crates/smoketests/tests/smoketests/client_connection_errors.rs +++ b/crates/smoketests/tests/smoketests/client_connection_errors.rs @@ -2,62 +2,10 @@ use spacetimedb_smoketests::Smoketest; -const MODULE_CODE_REJECT: &str = r#" -use spacetimedb::{ReducerContext, Table}; - -#[spacetimedb::table(name = all_u8s, public)] -pub struct AllU8s { - number: u8, -} - -#[spacetimedb::reducer(init)] -pub fn init(ctx: &ReducerContext) { - for i in u8::MIN..=u8::MAX { - ctx.db.all_u8s().insert(AllU8s { number: i }); - } -} - -#[spacetimedb::reducer(client_connected)] -pub fn identity_connected(_ctx: &ReducerContext) -> Result<(), String> { - Err("Rejecting connection from client".to_string()) -} - -#[spacetimedb::reducer(client_disconnected)] -pub fn identity_disconnected(_ctx: &ReducerContext) { - panic!("This should never be called, since we reject all connections!") -} -"#; - -const MODULE_CODE_DISCONNECT_PANIC: &str = r#" -use spacetimedb::{ReducerContext, Table}; - -#[spacetimedb::table(name = all_u8s, public)] -pub struct AllU8s { - number: u8, -} - -#[spacetimedb::reducer(init)] -pub fn init(ctx: &ReducerContext) { - for i in u8::MIN..=u8::MAX { - ctx.db.all_u8s().insert(AllU8s { number: i }); - } -} - -#[spacetimedb::reducer(client_connected)] -pub fn identity_connected(_ctx: &ReducerContext) -> Result<(), String> { - Ok(()) -} - -#[spacetimedb::reducer(client_disconnected)] -pub fn identity_disconnected(_ctx: &ReducerContext) { - panic!("This should be called, but the `st_client` row should still be deleted") -} -"#; - /// Test that client_connected returning an error rejects the connection #[test] fn test_client_connected_error_rejects_connection() { - let test = Smoketest::builder().module_code(MODULE_CODE_REJECT).build(); + let test = Smoketest::builder().precompiled_module("client-connection-reject").build(); // Subscribe should fail because client_connected returns an error let result = test.subscribe(&["SELECT * FROM all_u8s"], 0); @@ -82,7 +30,7 @@ fn test_client_connected_error_rejects_connection() { /// Test that client_disconnected panicking still cleans up the st_client row #[test] fn test_client_disconnected_error_still_deletes_st_client() { - let test = Smoketest::builder().module_code(MODULE_CODE_DISCONNECT_PANIC).build(); + let test = Smoketest::builder().precompiled_module("client-connection-disconnect-panic").build(); // Subscribe should succeed (client_connected returns Ok) let result = test.subscribe(&["SELECT * FROM all_u8s"], 0); diff --git a/crates/smoketests/tests/smoketests/confirmed_reads.rs b/crates/smoketests/tests/smoketests/confirmed_reads.rs index 413211571bc..7dedad1a2f5 100644 --- a/crates/smoketests/tests/smoketests/confirmed_reads.rs +++ b/crates/smoketests/tests/smoketests/confirmed_reads.rs @@ -6,24 +6,10 @@ use spacetimedb_smoketests::Smoketest; -const MODULE_CODE: &str = r#" -use spacetimedb::{ReducerContext, Table}; - -#[spacetimedb::table(name = person, public)] -pub struct Person { - name: String, -} - -#[spacetimedb::reducer] -pub fn add(ctx: &ReducerContext, name: String) { - ctx.db.person().insert(Person { name }); -} -"#; - /// Tests that subscribing with confirmed=true receives updates #[test] fn test_confirmed_reads_receive_updates() { - let test = Smoketest::builder().module_code(MODULE_CODE).build(); + let test = Smoketest::builder().precompiled_module("confirmed-reads").build(); // Start subscription in background with confirmed flag let sub = test @@ -62,7 +48,7 @@ fn test_confirmed_reads_receive_updates() { /// Tests that an SQL operation with confirmed=true returns a result #[test] fn test_sql_with_confirmed_reads_receives_result() { - let test = Smoketest::builder().module_code(MODULE_CODE).build(); + let test = Smoketest::builder().precompiled_module("confirmed-reads").build(); // Insert with confirmed test.sql_confirmed("INSERT INTO person (name) VALUES ('Horst')") diff --git a/crates/smoketests/tests/smoketests/connect_disconnect_from_cli.rs b/crates/smoketests/tests/smoketests/connect_disconnect_from_cli.rs index c5eeb4d87a0..58c9d5d6f55 100644 --- a/crates/smoketests/tests/smoketests/connect_disconnect_from_cli.rs +++ b/crates/smoketests/tests/smoketests/connect_disconnect_from_cli.rs @@ -2,29 +2,10 @@ use spacetimedb_smoketests::Smoketest; -const MODULE_CODE: &str = r#" -use spacetimedb::{log, ReducerContext}; - -#[spacetimedb::reducer(client_connected)] -pub fn connected(_ctx: &ReducerContext) { - log::info!("_connect called"); -} - -#[spacetimedb::reducer(client_disconnected)] -pub fn disconnected(_ctx: &ReducerContext) { - log::info!("disconnect called"); -} - -#[spacetimedb::reducer] -pub fn say_hello(_ctx: &ReducerContext) { - log::info!("Hello, World!"); -} -"#; - /// Ensure that the connect and disconnect functions are called when invoking a reducer from the CLI #[test] fn test_conn_disconn() { - let test = Smoketest::builder().module_code(MODULE_CODE).build(); + let test = Smoketest::builder().precompiled_module("connect-disconnect").build(); test.call("say_hello", &[]).unwrap(); diff --git a/crates/smoketests/tests/smoketests/delete_database.rs b/crates/smoketests/tests/smoketests/delete_database.rs index fc4bcddf629..014bf0cf35a 100644 --- a/crates/smoketests/tests/smoketests/delete_database.rs +++ b/crates/smoketests/tests/smoketests/delete_database.rs @@ -4,52 +4,12 @@ use spacetimedb_smoketests::Smoketest; use std::thread; use std::time::Duration; -const MODULE_CODE: &str = r#" -use spacetimedb::{ReducerContext, Table, duration}; - -#[spacetimedb::table(name = counter, public)] -pub struct Counter { - #[primary_key] - id: u64, - val: u64 -} - -#[spacetimedb::table(name = scheduled_counter, public, scheduled(inc, at = sched_at))] -pub struct ScheduledCounter { - #[primary_key] - #[auto_inc] - scheduled_id: u64, - sched_at: spacetimedb::ScheduleAt, -} - -#[spacetimedb::reducer] -pub fn inc(ctx: &ReducerContext, arg: ScheduledCounter) { - if let Some(mut counter) = ctx.db.counter().id().find(arg.scheduled_id) { - counter.val += 1; - ctx.db.counter().id().update(counter); - } else { - ctx.db.counter().insert(Counter { - id: arg.scheduled_id, - val: 1, - }); - } -} - -#[spacetimedb::reducer(init)] -pub fn init(ctx: &ReducerContext) { - ctx.db.scheduled_counter().insert(ScheduledCounter { - scheduled_id: 0, - sched_at: duration!(100ms).into(), - }); -} -"#; - /// Test that deleting a database stops the module. /// The module is considered stopped if its scheduled reducer stops /// producing update events. #[test] fn test_delete_database() { - let mut test = Smoketest::builder().module_code(MODULE_CODE).autopublish(false).build(); + let mut test = Smoketest::builder().precompiled_module("delete-database").autopublish(false).build(); let name = format!("test-db-{}", std::process::id()); test.publish_module_named(&name, false).unwrap(); diff --git a/crates/smoketests/tests/smoketests/describe.rs b/crates/smoketests/tests/smoketests/describe.rs index a00b723aae6..0778722b014 100644 --- a/crates/smoketests/tests/smoketests/describe.rs +++ b/crates/smoketests/tests/smoketests/describe.rs @@ -2,32 +2,10 @@ use spacetimedb_smoketests::Smoketest; -const MODULE_CODE: &str = r#" -use spacetimedb::{log, ReducerContext, Table}; - -#[spacetimedb::table(name = person)] -pub struct Person { - name: String, -} - -#[spacetimedb::reducer] -pub fn add(ctx: &ReducerContext, name: String) { - ctx.db.person().insert(Person { name }); -} - -#[spacetimedb::reducer] -pub fn say_hello(ctx: &ReducerContext) { - for person in ctx.db.person().iter() { - log::info!("Hello, {}!", person.name); - } - log::info!("Hello, World!"); -} -"#; - /// Check describing a module #[test] fn test_describe() { - let test = Smoketest::builder().module_code(MODULE_CODE).build(); + let test = Smoketest::builder().precompiled_module("describe").build(); let identity = test.database_identity.as_ref().unwrap(); diff --git a/crates/smoketests/tests/smoketests/dml.rs b/crates/smoketests/tests/smoketests/dml.rs index 5458c0d9830..4d2e1799015 100644 --- a/crates/smoketests/tests/smoketests/dml.rs +++ b/crates/smoketests/tests/smoketests/dml.rs @@ -2,22 +2,13 @@ use spacetimedb_smoketests::Smoketest; -const MODULE_CODE: &str = r#" -use spacetimedb::{ReducerContext, Table}; - -#[spacetimedb::table(name = t, public)] -pub struct T { - name: String, -} -"#; - /// Test that we receive subscription updates from DML #[test] fn test_subscribe() { use std::thread; use std::time::Duration; - let test = Smoketest::builder().module_code(MODULE_CODE).build(); + let test = Smoketest::builder().precompiled_module("dml").build(); // Start subscription FIRST (in background), matching Python semantics let sub = test.subscribe_background(&["SELECT * FROM t"], 2).unwrap(); diff --git a/crates/smoketests/tests/smoketests/fail_initial_publish.rs b/crates/smoketests/tests/smoketests/fail_initial_publish.rs index 85e0a7294b7..c56ad84c732 100644 --- a/crates/smoketests/tests/smoketests/fail_initial_publish.rs +++ b/crates/smoketests/tests/smoketests/fail_initial_publish.rs @@ -16,19 +16,6 @@ pub struct Person { const HIDE_PEOPLE_EXCEPT_ME: Filter = Filter::Sql("SELECT * FROM Person WHERE name = 'me'"); "#; -/// Fixed module code with correct table name -const MODULE_CODE_FIXED: &str = r#" -use spacetimedb::{client_visibility_filter, Filter}; - -#[spacetimedb::table(name = person, public)] -pub struct Person { - name: String, -} - -#[client_visibility_filter] -const HIDE_PEOPLE_EXCEPT_ME: Filter = Filter::Sql("SELECT * FROM person WHERE name = 'me'"); -"#; - const FIXED_QUERY: &str = r#""sql": "SELECT * FROM person WHERE name = 'me'""#; /// This tests that publishing an invalid module does not leave a broken entry in the control DB. @@ -61,7 +48,7 @@ fn test_fail_initial_publish() { // We can publish a fixed module under the same database name. // This used to be broken; the failed initial publish would leave // the control database in a bad state. - test.write_module_code(MODULE_CODE_FIXED).unwrap(); + test.use_precompiled_module("fail-initial-publish-fixed"); test.publish_module_named(&name, false).unwrap(); let describe_output = test diff --git a/crates/smoketests/tests/smoketests/filtering.rs b/crates/smoketests/tests/smoketests/filtering.rs index 6f39a504b0e..d1ba54d07e4 100644 --- a/crates/smoketests/tests/smoketests/filtering.rs +++ b/crates/smoketests/tests/smoketests/filtering.rs @@ -2,195 +2,10 @@ use spacetimedb_smoketests::Smoketest; -const MODULE_CODE: &str = r#" -use spacetimedb::{log, Identity, ReducerContext, Table}; - -#[spacetimedb::table(name = person)] -pub struct Person { - #[unique] - id: i32, - - name: String, - - #[unique] - nick: String, -} - -#[spacetimedb::reducer] -pub fn insert_person(ctx: &ReducerContext, id: i32, name: String, nick: String) { - ctx.db.person().insert(Person { id, name, nick} ); -} - -#[spacetimedb::reducer] -pub fn insert_person_twice(ctx: &ReducerContext, id: i32, name: String, nick: String) { - // We'd like to avoid an error due to a set-semantic error. - let name2 = format!("{name}2"); - ctx.db.person().insert(Person { id, name, nick: nick.clone()} ); - match ctx.db.person().try_insert(Person { id, name: name2, nick: nick.clone()}) { - Ok(_) => {}, - Err(_) => { - log::info!("UNIQUE CONSTRAINT VIOLATION ERROR: id = {}, nick = {}", id, nick) - } - } -} - -#[spacetimedb::reducer] -pub fn delete_person(ctx: &ReducerContext, id: i32) { - ctx.db.person().id().delete(&id); -} - -#[spacetimedb::reducer] -pub fn find_person(ctx: &ReducerContext, id: i32) { - match ctx.db.person().id().find(&id) { - Some(person) => log::info!("UNIQUE FOUND: id {}: {}", id, person.name), - None => log::info!("UNIQUE NOT FOUND: id {}", id), - } -} - -#[spacetimedb::reducer] -pub fn find_person_read_only(ctx: &ReducerContext, id: i32) { - let ctx = ctx.as_read_only(); - match ctx.db.person().id().find(&id) { - Some(person) => log::info!("UNIQUE FOUND: id {}: {}", id, person.name), - None => log::info!("UNIQUE NOT FOUND: id {}", id), - } -} - -#[spacetimedb::reducer] -pub fn find_person_by_name(ctx: &ReducerContext, name: String) { - for person in ctx.db.person().iter().filter(|p| p.name == name) { - log::info!("UNIQUE FOUND: id {}: {} aka {}", person.id, person.name, person.nick); - } -} - -#[spacetimedb::reducer] -pub fn find_person_by_nick(ctx: &ReducerContext, nick: String) { - match ctx.db.person().nick().find(&nick) { - Some(person) => log::info!("UNIQUE FOUND: id {}: {}", person.id, person.nick), - None => log::info!("UNIQUE NOT FOUND: nick {}", nick), - } -} - -#[spacetimedb::reducer] -pub fn find_person_by_nick_read_only(ctx: &ReducerContext, nick: String) { - let ctx = ctx.as_read_only(); - match ctx.db.person().nick().find(&nick) { - Some(person) => log::info!("UNIQUE FOUND: id {}: {}", person.id, person.nick), - None => log::info!("UNIQUE NOT FOUND: nick {}", nick), - } -} - -#[spacetimedb::table(name = nonunique_person)] -pub struct NonuniquePerson { - #[index(btree)] - id: i32, - name: String, - is_human: bool, -} - -#[spacetimedb::reducer] -pub fn insert_nonunique_person(ctx: &ReducerContext, id: i32, name: String, is_human: bool) { - ctx.db.nonunique_person().insert(NonuniquePerson { id, name, is_human } ); -} - -#[spacetimedb::reducer] -pub fn find_nonunique_person(ctx: &ReducerContext, id: i32) { - for person in ctx.db.nonunique_person().id().filter(&id) { - log::info!("NONUNIQUE FOUND: id {}: {}", id, person.name) - } -} - -#[spacetimedb::reducer] -pub fn find_nonunique_person_read_only(ctx: &ReducerContext, id: i32) { - let ctx = ctx.as_read_only(); - for person in ctx.db.nonunique_person().id().filter(&id) { - log::info!("NONUNIQUE FOUND: id {}: {}", id, person.name) - } -} - -#[spacetimedb::reducer] -pub fn find_nonunique_humans(ctx: &ReducerContext) { - for person in ctx.db.nonunique_person().iter().filter(|p| p.is_human) { - log::info!("HUMAN FOUND: id {}: {}", person.id, person.name); - } -} - -#[spacetimedb::reducer] -pub fn find_nonunique_non_humans(ctx: &ReducerContext) { - for person in ctx.db.nonunique_person().iter().filter(|p| !p.is_human) { - log::info!("NON-HUMAN FOUND: id {}: {}", person.id, person.name); - } -} - -// Ensure that [Identity] is filterable and a legal unique column. -#[spacetimedb::table(name = identified_person)] -struct IdentifiedPerson { - #[unique] - identity: Identity, - name: String, -} - -fn identify(id_number: u64) -> Identity { - let mut bytes = [0u8; 32]; - bytes[..8].clone_from_slice(&id_number.to_le_bytes()); - Identity::from_byte_array(bytes) -} - -#[spacetimedb::reducer] -fn insert_identified_person(ctx: &ReducerContext, id_number: u64, name: String) { - let identity = identify(id_number); - ctx.db.identified_person().insert(IdentifiedPerson { identity, name }); -} - -#[spacetimedb::reducer] -fn find_identified_person(ctx: &ReducerContext, id_number: u64) { - let identity = identify(id_number); - match ctx.db.identified_person().identity().find(&identity) { - Some(person) => log::info!("IDENTIFIED FOUND: {}", person.name), - None => log::info!("IDENTIFIED NOT FOUND"), - } -} - -// Ensure that indices on non-unique columns behave as we expect. -#[spacetimedb::table(name = indexed_person)] -struct IndexedPerson { - #[unique] - id: i32, - given_name: String, - #[index(btree)] - surname: String, -} - -#[spacetimedb::reducer] -fn insert_indexed_person(ctx: &ReducerContext, id: i32, given_name: String, surname: String) { - ctx.db.indexed_person().insert(IndexedPerson { id, given_name, surname }); -} - -#[spacetimedb::reducer] -fn delete_indexed_person(ctx: &ReducerContext, id: i32) { - ctx.db.indexed_person().id().delete(&id); -} - -#[spacetimedb::reducer] -fn find_indexed_people(ctx: &ReducerContext, surname: String) { - for person in ctx.db.indexed_person().surname().filter(&surname) { - log::info!("INDEXED FOUND: id {}: {}, {}", person.id, person.surname, person.given_name); - } -} - -#[spacetimedb::reducer] -fn find_indexed_people_read_only(ctx: &ReducerContext, surname: String) { - let ctx = ctx.as_read_only(); - for person in ctx.db.indexed_person().surname().filter(&surname) { - log::info!("INDEXED FOUND: id {}: {}, {}", person.id, person.surname, person.given_name); - } -} -"#; - /// Test filtering reducers #[test] fn test_filtering() { - let test = Smoketest::builder().module_code(MODULE_CODE).build(); + let test = Smoketest::builder().precompiled_module("filtering").build(); test.call("insert_person", &["23", r#""Alice""#, r#""al""#]).unwrap(); test.call("insert_person", &["42", r#""Bob""#, r#""bo""#]).unwrap(); diff --git a/crates/smoketests/tests/smoketests/module_nested_op.rs b/crates/smoketests/tests/smoketests/module_nested_op.rs index 99d38a4acf0..53a902b362c 100644 --- a/crates/smoketests/tests/smoketests/module_nested_op.rs +++ b/crates/smoketests/tests/smoketests/module_nested_op.rs @@ -2,52 +2,10 @@ use spacetimedb_smoketests::Smoketest; -const MODULE_CODE: &str = r#" -use spacetimedb::{log, ReducerContext, Table}; - -#[spacetimedb::table(name = account)] -pub struct Account { - name: String, - #[unique] - id: i32, -} - -#[spacetimedb::table(name = friends)] -pub struct Friends { - friend_1: i32, - friend_2: i32, -} - -#[spacetimedb::reducer] -pub fn create_account(ctx: &ReducerContext, account_id: i32, name: String) { - ctx.db.account().insert(Account { id: account_id, name } ); -} - -#[spacetimedb::reducer] -pub fn add_friend(ctx: &ReducerContext, my_id: i32, their_id: i32) { - // Make sure our friend exists - for account in ctx.db.account().iter() { - if account.id == their_id { - ctx.db.friends().insert(Friends { friend_1: my_id, friend_2: their_id }); - return; - } - } -} - -#[spacetimedb::reducer] -pub fn say_friends(ctx: &ReducerContext) { - for friendship in ctx.db.friends().iter() { - let friend1 = ctx.db.account().id().find(&friendship.friend_1).unwrap(); - let friend2 = ctx.db.account().id().find(&friendship.friend_2).unwrap(); - log::info!("{} is friends with {}", friend1.name, friend2.name); - } -} -"#; - /// This tests uploading a basic module and calling some functions and checking logs afterwards. #[test] fn test_module_nested_op() { - let test = Smoketest::builder().module_code(MODULE_CODE).build(); + let test = Smoketest::builder().precompiled_module("module-nested-op").build(); test.call("create_account", &["1", r#""House""#]).unwrap(); test.call("create_account", &["2", r#""Wilson""#]).unwrap(); diff --git a/crates/smoketests/tests/smoketests/modules.rs b/crates/smoketests/tests/smoketests/modules.rs index 2340bfa1e16..0b03f50c5b6 100644 --- a/crates/smoketests/tests/smoketests/modules.rs +++ b/crates/smoketests/tests/smoketests/modules.rs @@ -2,31 +2,6 @@ use spacetimedb_smoketests::Smoketest; -const MODULE_CODE: &str = r#" -use spacetimedb::{log, ReducerContext, Table}; - -#[spacetimedb::table(name = person)] -pub struct Person { - #[primary_key] - #[auto_inc] - id: u64, - name: String, -} - -#[spacetimedb::reducer] -pub fn add(ctx: &ReducerContext, name: String) { - ctx.db.person().insert(Person { id: 0, name }); -} - -#[spacetimedb::reducer] -pub fn say_hello(ctx: &ReducerContext) { - for person in ctx.db.person().iter() { - log::info!("Hello, {}!", person.name); - } - log::info!("Hello, World!"); -} -"#; - /// Breaking change: adds a new column to Person const MODULE_CODE_BREAKING: &str = r#" #[spacetimedb::table(name = person)] @@ -39,33 +14,10 @@ pub struct Person { } "#; -/// Non-breaking change: adds a new table -const MODULE_CODE_ADD_TABLE: &str = r#" -use spacetimedb::{log, ReducerContext, Table}; - -#[spacetimedb::table(name = person)] -pub struct Person { - #[primary_key] - #[auto_inc] - id: u64, - name: String, -} - -#[spacetimedb::table(name = pets)] -pub struct Pet { - species: String, -} - -#[spacetimedb::reducer] -pub fn are_we_updated_yet(ctx: &ReducerContext) { - log::info!("MODULE UPDATED"); -} -"#; - /// Test publishing a module without the --delete-data option #[test] fn test_module_update() { - let mut test = Smoketest::builder().module_code(MODULE_CODE).autopublish(false).build(); + let mut test = Smoketest::builder().precompiled_module("modules-basic").autopublish(false).build(); let name = format!("test-db-{}", std::process::id()); @@ -101,7 +53,7 @@ fn test_module_update() { test.call("say_hello", &[]).unwrap(); // Adding a table is ok - test.write_module_code(MODULE_CODE_ADD_TABLE).unwrap(); + test.use_precompiled_module("modules-add-table"); test.publish_module_named(&name, false).unwrap(); test.call("are_we_updated_yet", &[]).unwrap(); @@ -116,7 +68,7 @@ fn test_module_update() { /// Test uploading a basic module and calling some functions and checking logs #[test] fn test_upload_module() { - let test = Smoketest::builder().module_code(MODULE_CODE).build(); + let test = Smoketest::builder().precompiled_module("modules-basic").build(); test.call("add", &["Robert"]).unwrap(); test.call("add", &["Julie"]).unwrap(); diff --git a/crates/smoketests/tests/smoketests/namespaces.rs b/crates/smoketests/tests/smoketests/namespaces.rs index d83f416f381..b980f0b21c5 100644 --- a/crates/smoketests/tests/smoketests/namespaces.rs +++ b/crates/smoketests/tests/smoketests/namespaces.rs @@ -1,11 +1,15 @@ //! Namespace tests translated from smoketests/tests/namespaces.py +//! +//! These tests use `module_code()` instead of `precompiled_module()` because +//! they use `spacetime generate --project-path` which requires a Cargo.toml +//! to detect the module language. They don't actually compile the module. use spacetimedb_smoketests::Smoketest; use std::fs; use std::path::Path; -/// Template module code matching the Python test's default -const TEMPLATE_MODULE_CODE: &str = r#" +/// Module code for namespace tests +const MODULE_CODE: &str = r#" use spacetimedb::{ReducerContext, Table}; #[spacetimedb::table(name = person, public)] @@ -14,19 +18,13 @@ pub struct Person { } #[spacetimedb::reducer(init)] -pub fn init(_ctx: &ReducerContext) { - // Called when the module is initially published -} +pub fn init(_ctx: &ReducerContext) {} #[spacetimedb::reducer(client_connected)] -pub fn identity_connected(_ctx: &ReducerContext) { - // Called everytime a new client connects -} +pub fn identity_connected(_ctx: &ReducerContext) {} #[spacetimedb::reducer(client_disconnected)] -pub fn identity_disconnected(_ctx: &ReducerContext) { - // Called everytime a client disconnects -} +pub fn identity_disconnected(_ctx: &ReducerContext) {} #[spacetimedb::reducer] pub fn add(ctx: &ReducerContext, name: String) { @@ -64,7 +62,7 @@ fn count_matches(dir: &Path, needle: &str) -> usize { #[test] fn test_spacetimedb_ns_csharp() { let test = Smoketest::builder() - .module_code(TEMPLATE_MODULE_CODE) + .module_code(MODULE_CODE) .autopublish(false) .build(); @@ -99,7 +97,7 @@ fn test_spacetimedb_ns_csharp() { #[test] fn test_custom_ns_csharp() { let test = Smoketest::builder() - .module_code(TEMPLATE_MODULE_CODE) + .module_code(MODULE_CODE) .autopublish(false) .build(); diff --git a/crates/smoketests/tests/smoketests/new_user_flow.rs b/crates/smoketests/tests/smoketests/new_user_flow.rs index 7cc13388900..ddd522f014e 100644 --- a/crates/smoketests/tests/smoketests/new_user_flow.rs +++ b/crates/smoketests/tests/smoketests/new_user_flow.rs @@ -2,32 +2,10 @@ use spacetimedb_smoketests::Smoketest; -const MODULE_CODE: &str = r#" -use spacetimedb::{log, ReducerContext, Table}; - -#[spacetimedb::table(name = person)] -pub struct Person { - name: String -} - -#[spacetimedb::reducer] -pub fn add(ctx: &ReducerContext, name: String) { - ctx.db.person().insert(Person { name }); -} - -#[spacetimedb::reducer] -pub fn say_hello(ctx: &ReducerContext) { - for person in ctx.db.person().iter() { - log::info!("Hello, {}!", person.name); - } - log::info!("Hello, World!"); -} -"#; - /// Test the entirety of the new user flow. #[test] fn test_new_user_flow() { - let mut test = Smoketest::builder().module_code(MODULE_CODE).autopublish(false).build(); + let mut test = Smoketest::builder().precompiled_module("new-user-flow").autopublish(false).build(); // Create a new identity and publish test.new_identity().unwrap(); diff --git a/crates/smoketests/tests/smoketests/panic.rs b/crates/smoketests/tests/smoketests/panic.rs index 71c75470bbd..a6fe5e44075 100644 --- a/crates/smoketests/tests/smoketests/panic.rs +++ b/crates/smoketests/tests/smoketests/panic.rs @@ -2,31 +2,10 @@ use spacetimedb_smoketests::Smoketest; -const PANIC_MODULE_CODE: &str = r#" -use spacetimedb::{log, ReducerContext}; -use std::cell::RefCell; - -thread_local! { - static X: RefCell = RefCell::new(0); -} -#[spacetimedb::reducer] -fn first(_ctx: &ReducerContext) { - X.with(|x| { - let _x = x.borrow_mut(); - panic!() - }) -} -#[spacetimedb::reducer] -fn second(_ctx: &ReducerContext) { - X.with(|x| *x.borrow_mut()); - log::info!("Test Passed"); -} -"#; - /// Tests to check if a SpacetimeDB module can handle a panic without corrupting #[test] fn test_panic() { - let test = Smoketest::builder().module_code(PANIC_MODULE_CODE).build(); + let test = Smoketest::builder().precompiled_module("panic").build(); // First reducer should panic/fail let result = test.call("first", &[]); @@ -43,19 +22,10 @@ fn test_panic() { ); } -const REDUCER_ERROR_MODULE_CODE: &str = r#" -use spacetimedb::ReducerContext; - -#[spacetimedb::reducer] -fn fail(_ctx: &ReducerContext) -> Result<(), String> { - Err("oopsie :(".into()) -} -"#; - /// Tests to ensure an error message returned from a reducer gets printed to logs #[test] fn test_reducer_error_message() { - let test = Smoketest::builder().module_code(REDUCER_ERROR_MODULE_CODE).build(); + let test = Smoketest::builder().precompiled_module("panic-error").build(); // Reducer should fail with error let result = test.call("fail", &[]); diff --git a/crates/smoketests/tests/smoketests/permissions.rs b/crates/smoketests/tests/smoketests/permissions.rs index d42f902610d..8f3ff41b2b5 100644 --- a/crates/smoketests/tests/smoketests/permissions.rs +++ b/crates/smoketests/tests/smoketests/permissions.rs @@ -2,35 +2,10 @@ use spacetimedb_smoketests::Smoketest; -const MODULE_CODE_PRIVATE: &str = r#" -use spacetimedb::{ReducerContext, Table}; - -#[spacetimedb::table(name = secret, private)] -pub struct Secret { - answer: u8, -} - -#[spacetimedb::table(name = common_knowledge, public)] -pub struct CommonKnowledge { - thing: String, -} - -#[spacetimedb::reducer(init)] -pub fn init(ctx: &ReducerContext) { - ctx.db.secret().insert(Secret { answer: 42 }); -} - -#[spacetimedb::reducer] -pub fn do_thing(ctx: &ReducerContext, thing: String) { - ctx.db.secret().insert(Secret { answer: 20 }); - ctx.db.common_knowledge().insert(CommonKnowledge { thing }); -} -"#; - /// Ensure that a private table can only be queried by the database owner #[test] fn test_private_table() { - let test = Smoketest::builder().module_code(MODULE_CODE_PRIVATE).build(); + let test = Smoketest::builder().precompiled_module("permissions-private").build(); // Owner can query private table test.assert_sql( @@ -86,21 +61,10 @@ fn test_cannot_delete_others_database() { assert!(result.is_err(), "Expected delete to fail for non-owner"); } -const MODULE_CODE_LIFECYCLE: &str = r#" -#[spacetimedb::reducer(init)] -fn lifecycle_init(_ctx: &spacetimedb::ReducerContext) {} - -#[spacetimedb::reducer(client_connected)] -fn lifecycle_client_connected(_ctx: &spacetimedb::ReducerContext) {} - -#[spacetimedb::reducer(client_disconnected)] -fn lifecycle_client_disconnected(_ctx: &spacetimedb::ReducerContext) {} -"#; - /// Ensure that lifecycle reducers (init, on_connect, etc) can't be called directly #[test] fn test_lifecycle_reducers_cant_be_called() { - let test = Smoketest::builder().module_code(MODULE_CODE_LIFECYCLE).build(); + let test = Smoketest::builder().precompiled_module("permissions-lifecycle").build(); let lifecycle_kinds = ["init", "client_connected", "client_disconnected"]; diff --git a/crates/smoketests/tests/smoketests/pg_wire.rs b/crates/smoketests/tests/smoketests/pg_wire.rs index 71bcacd754c..67f7227ffdb 100644 --- a/crates/smoketests/tests/smoketests/pg_wire.rs +++ b/crates/smoketests/tests/smoketests/pg_wire.rs @@ -3,168 +3,6 @@ use spacetimedb_smoketests::{have_psql, Smoketest}; -const MODULE_CODE: &str = r#" -use spacetimedb::sats::{i256, u256}; -use spacetimedb::{ConnectionId, Identity, ReducerContext, SpacetimeType, Table, Timestamp, TimeDuration, Uuid}; - -#[derive(Copy, Clone)] -#[spacetimedb::table(name = t_ints, public)] -pub struct TInts { - i8: i8, - i16: i16, - i32: i32, - i64: i64, - i128: i128, - i256: i256, -} - -#[spacetimedb::table(name = t_ints_tuple, public)] -pub struct TIntsTuple { - tuple: TInts, -} - -#[derive(Copy, Clone)] -#[spacetimedb::table(name = t_uints, public)] -pub struct TUints { - u8: u8, - u16: u16, - u32: u32, - u64: u64, - u128: u128, - u256: u256, -} - -#[spacetimedb::table(name = t_uints_tuple, public)] -pub struct TUintsTuple { - tuple: TUints, -} - -#[derive(Clone)] -#[spacetimedb::table(name = t_others, public)] -pub struct TOthers { - bool: bool, - f32: f32, - f64: f64, - str: String, - bytes: Vec, - identity: Identity, - connection_id: ConnectionId, - timestamp: Timestamp, - duration: TimeDuration, - uuid: Uuid, -} - -#[spacetimedb::table(name = t_others_tuple, public)] -pub struct TOthersTuple { - tuple: TOthers -} - -#[derive(SpacetimeType, Debug, Clone, Copy)] -pub enum Action { - Inactive, - Active, -} - -#[derive(SpacetimeType, Debug, Clone, Copy)] -pub enum Color { - Gray(u8), -} - -#[derive(Copy, Clone)] -#[spacetimedb::table(name = t_simple_enum, public)] -pub struct TSimpleEnum { - id: u32, - action: Action, -} - -#[spacetimedb::table(name = t_enum, public)] -pub struct TEnum { - id: u32, - color: Color, -} - -#[spacetimedb::table(name = t_nested, public)] -pub struct TNested { - en: TEnum, - se: TSimpleEnum, - ints: TInts, -} - -#[derive(Clone)] -#[spacetimedb::table(name = t_enums)] -pub struct TEnums { - bool_opt: Option, - bool_result: Result, - action: Action, -} - -#[spacetimedb::table(name = t_enums_tuple)] -pub struct TEnumsTuple { - tuple: TEnums, -} - -#[spacetimedb::reducer] -pub fn test(ctx: &ReducerContext) { - let tuple = TInts { - i8: -25, - i16: -3224, - i32: -23443, - i64: -2344353, - i128: -234434897853, - i256: (-234434897853i128).into(), - }; - let ints = tuple; - ctx.db.t_ints().insert(tuple); - ctx.db.t_ints_tuple().insert(TIntsTuple { tuple }); - - let tuple = TUints { - u8: 105, - u16: 1050, - u32: 83892, - u64: 48937498, - u128: 4378528978889, - u256: 4378528978889u128.into(), - }; - ctx.db.t_uints().insert(tuple); - ctx.db.t_uints_tuple().insert(TUintsTuple { tuple }); - - let tuple = TOthers { - bool: true, - f32: 594806.58906, - f64: -3454353.345389043278459, - str: "This is spacetimedb".to_string(), - bytes: vec!(1, 2, 3, 4, 5, 6, 7), - identity: Identity::ONE, - connection_id: ConnectionId::ZERO, - timestamp: Timestamp::UNIX_EPOCH, - duration: TimeDuration::from_micros(1000 * 10000), - uuid: Uuid::NIL, - }; - ctx.db.t_others().insert(tuple.clone()); - ctx.db.t_others_tuple().insert(TOthersTuple { tuple }); - - ctx.db.t_simple_enum().insert(TSimpleEnum { id: 1, action: Action::Inactive }); - ctx.db.t_simple_enum().insert(TSimpleEnum { id: 2, action: Action::Active }); - - ctx.db.t_enum().insert(TEnum { id: 1, color: Color::Gray(128) }); - - ctx.db.t_nested().insert(TNested { - en: TEnum { id: 1, color: Color::Gray(128) }, - se: TSimpleEnum { id: 2, action: Action::Active }, - ints, - }); - - let tuple = TEnums { - bool_opt: Some(true), - bool_result: Ok(false), - action: Action::Active, - }; - - ctx.db.t_enums().insert(tuple.clone()); - ctx.db.t_enums_tuple().insert(TEnumsTuple { tuple }); -} -"#; - /// Test SQL output formatting via psql #[test] fn test_sql_format() { @@ -174,7 +12,7 @@ fn test_sql_format() { } let mut test = Smoketest::builder() - .module_code(MODULE_CODE) + .precompiled_module("pg-wire") .pg_port(5433) // Use non-standard port to avoid conflicts .autopublish(false) .build(); @@ -247,7 +85,7 @@ fn test_failures() { } let mut test = Smoketest::builder() - .module_code(MODULE_CODE) + .precompiled_module("pg-wire") .pg_port(5434) // Use different port from test_sql_format .autopublish(false) .build(); diff --git a/crates/smoketests/tests/smoketests/restart.rs b/crates/smoketests/tests/smoketests/restart.rs index ab412604736..948220fd320 100644 --- a/crates/smoketests/tests/smoketests/restart.rs +++ b/crates/smoketests/tests/smoketests/restart.rs @@ -3,74 +3,12 @@ use spacetimedb_smoketests::Smoketest; -const PERSON_MODULE: &str = r#" -use spacetimedb::{log, ReducerContext, Table}; - -#[spacetimedb::table(name = person, index(name = name_idx, btree(columns = [name])))] -pub struct Person { - #[primary_key] - #[auto_inc] - id: u32, - name: String, -} - -#[spacetimedb::reducer] -pub fn add(ctx: &ReducerContext, name: String) { - ctx.db.person().insert(Person { id: 0, name }); -} - -#[spacetimedb::reducer] -pub fn say_hello(ctx: &ReducerContext) { - for person in ctx.db.person().iter() { - log::info!("Hello, {}!", person.name); - } - log::info!("Hello, World!"); -} -"#; - -const CONNECTED_CLIENT_MODULE: &str = r#" -use log::info; -use spacetimedb::{ConnectionId, Identity, ReducerContext, Table}; - -#[spacetimedb::table(name = connected_client)] -pub struct ConnectedClient { - identity: Identity, - connection_id: ConnectionId, -} - -#[spacetimedb::reducer(client_connected)] -fn on_connect(ctx: &ReducerContext) { - ctx.db.connected_client().insert(ConnectedClient { - identity: ctx.sender, - connection_id: ctx.connection_id.expect("sender connection id unset"), - }); -} - -#[spacetimedb::reducer(client_disconnected)] -fn on_disconnect(ctx: &ReducerContext) { - let sender_identity = &ctx.sender; - let sender_connection_id = ctx.connection_id.as_ref().expect("sender connection id unset"); - let match_client = |row: &ConnectedClient| { - &row.identity == sender_identity && &row.connection_id == sender_connection_id - }; - if let Some(client) = ctx.db.connected_client().iter().find(match_client) { - ctx.db.connected_client().delete(client); - } -} - -#[spacetimedb::reducer] -fn print_num_connected(ctx: &ReducerContext) { - let n = ctx.db.connected_client().count(); - info!("CONNECTED CLIENTS: {n}") -} -"#; - /// Test data persistence across server restart. /// /// This tests to see if SpacetimeDB can be queried after a restart. #[test] fn test_restart_module() { - let mut test = Smoketest::builder().module_code(PERSON_MODULE).build(); + let mut test = Smoketest::builder().precompiled_module("restart-person").build(); test.call("add", &["Robert"]).unwrap(); @@ -102,7 +40,7 @@ fn test_restart_module() { /// Test SQL queries work after restart. #[test] fn test_restart_sql() { - let mut test = Smoketest::builder().module_code(PERSON_MODULE).build(); + let mut test = Smoketest::builder().precompiled_module("restart-person").build(); test.call("add", &["Robert"]).unwrap(); test.call("add", &["Julie"]).unwrap(); @@ -121,7 +59,7 @@ fn test_restart_sql() { /// Test clients are auto-disconnected on restart. #[test] fn test_restart_auto_disconnect() { - let mut test = Smoketest::builder().module_code(CONNECTED_CLIENT_MODULE).build(); + let mut test = Smoketest::builder().precompiled_module("restart-connected-client").build(); // Start two subscribers in the background let sub1 = test @@ -158,51 +96,6 @@ fn test_restart_auto_disconnect() { ); } -// Module code for add_remove_index test (without indices) -const ADD_REMOVE_MODULE: &str = r#" -use spacetimedb::{ReducerContext, Table}; - -#[spacetimedb::table(name = t1)] -pub struct T1 { id: u64 } - -#[spacetimedb::table(name = t2)] -pub struct T2 { id: u64 } - -#[spacetimedb::reducer(init)] -pub fn init(ctx: &ReducerContext) { - for id in 0..1_000 { - ctx.db.t1().insert(T1 { id }); - ctx.db.t2().insert(T2 { id }); - } -} -"#; - -// Module code for add_remove_index test (with indices) -const ADD_REMOVE_MODULE_INDEXED: &str = r#" -use spacetimedb::{ReducerContext, Table}; - -#[spacetimedb::table(name = t1)] -pub struct T1 { #[index(btree)] id: u64 } - -#[spacetimedb::table(name = t2)] -pub struct T2 { #[index(btree)] id: u64 } - -#[spacetimedb::reducer(init)] -pub fn init(ctx: &ReducerContext) { - for id in 0..1_000 { - ctx.db.t1().insert(T1 { id }); - ctx.db.t2().insert(T2 { id }); - } -} - -#[spacetimedb::reducer] -pub fn add(ctx: &ReducerContext) { - let id = 1_001; - ctx.db.t1().insert(T1 { id }); - ctx.db.t2().insert(T2 { id }); -} -"#; - const JOIN_QUERY: &str = "select t1.* from t1 join t2 on t1.id = t2.id where t2.id = 1001"; /// Test autoinc sequences work correctly after restart. @@ -216,7 +109,7 @@ const JOIN_QUERY: &str = "select t1.* from t1 join t2 on t1.id = t2.id where t2. #[test] fn test_add_remove_index_after_restart() { let mut test = Smoketest::builder() - .module_code(ADD_REMOVE_MODULE) + .precompiled_module("add-remove-index") .autopublish(false) .build(); @@ -233,7 +126,7 @@ fn test_add_remove_index_after_restart() { // Publish the indexed version. // Now we have indices, so the query should be accepted. - test.write_module_code(ADD_REMOVE_MODULE_INDEXED).unwrap(); + test.use_precompiled_module("add-remove-index-indexed"); test.publish_module_named(&name, false).unwrap(); // Subscription should work now @@ -252,7 +145,7 @@ fn test_add_remove_index_after_restart() { // Publish the unindexed version again, removing the index. // The initial subscription should be rejected again. - test.write_module_code(ADD_REMOVE_MODULE).unwrap(); + test.use_precompiled_module("add-remove-index"); test.publish_module_named(&name, false).unwrap(); let result = test.subscribe(&[JOIN_QUERY], 0); assert!(result.is_err(), "Expected subscription to fail after removing indices"); diff --git a/crates/smoketests/tests/smoketests/rls.rs b/crates/smoketests/tests/smoketests/rls.rs index d60f774159d..647b8d06b34 100644 --- a/crates/smoketests/tests/smoketests/rls.rs +++ b/crates/smoketests/tests/smoketests/rls.rs @@ -2,30 +2,10 @@ use spacetimedb_smoketests::Smoketest; -const MODULE_CODE: &str = r#" -use spacetimedb::{Identity, ReducerContext, Table}; - -#[spacetimedb::table(name = users, public)] -pub struct Users { - name: String, - identity: Identity, -} - -#[spacetimedb::client_visibility_filter] -const USER_FILTER: spacetimedb::Filter = spacetimedb::Filter::Sql( - "SELECT * FROM users WHERE identity = :sender" -); - -#[spacetimedb::reducer] -pub fn add_user(ctx: &ReducerContext, name: String) { - ctx.db.users().insert(Users { name, identity: ctx.sender }); -} -"#; - /// Tests for querying tables with RLS rules #[test] fn test_rls_rules() { - let test = Smoketest::builder().module_code(MODULE_CODE).build(); + let test = Smoketest::builder().precompiled_module("rls").build(); // Insert a user for Alice (current identity) test.call("add_user", &["Alice"]).unwrap(); diff --git a/crates/smoketests/tests/smoketests/schedule_reducer.rs b/crates/smoketests/tests/smoketests/schedule_reducer.rs index 9c602867f38..a7ae6daee95 100644 --- a/crates/smoketests/tests/smoketests/schedule_reducer.rs +++ b/crates/smoketests/tests/smoketests/schedule_reducer.rs @@ -4,50 +4,10 @@ use spacetimedb_smoketests::Smoketest; use std::thread; use std::time::Duration; -const CANCEL_REDUCER_MODULE_CODE: &str = r#" -use spacetimedb::{duration, log, ReducerContext, Table}; - -#[spacetimedb::reducer(init)] -fn init(ctx: &ReducerContext) { - let schedule = ctx.db.scheduled_reducer_args().insert(ScheduledReducerArgs { - num: 1, - scheduled_id: 0, - scheduled_at: duration!(100ms).into(), - }); - ctx.db.scheduled_reducer_args().scheduled_id().delete(&schedule.scheduled_id); - - let schedule = ctx.db.scheduled_reducer_args().insert(ScheduledReducerArgs { - num: 2, - scheduled_id: 0, - scheduled_at: duration!(1000ms).into(), - }); - do_cancel(ctx, schedule.scheduled_id); -} - -#[spacetimedb::table(name = scheduled_reducer_args, public, scheduled(reducer))] -pub struct ScheduledReducerArgs { - #[primary_key] - #[auto_inc] - scheduled_id: u64, - scheduled_at: spacetimedb::ScheduleAt, - num: i32, -} - -#[spacetimedb::reducer] -fn do_cancel(ctx: &ReducerContext, schedule_id: u64) { - ctx.db.scheduled_reducer_args().scheduled_id().delete(&schedule_id); -} - -#[spacetimedb::reducer] -fn reducer(_ctx: &ReducerContext, args: ScheduledReducerArgs) { - log::info!("the reducer ran: {}", args.num); -} -"#; - /// Ensure cancelling a reducer works #[test] fn test_cancel_reducer() { - let test = Smoketest::builder().module_code(CANCEL_REDUCER_MODULE_CODE).build(); + let test = Smoketest::builder().precompiled_module("schedule-cancel").build(); // Wait for any scheduled reducers to potentially run thread::sleep(Duration::from_secs(2)); @@ -61,40 +21,12 @@ fn test_cancel_reducer() { ); } -const SUBSCRIBE_SCHEDULED_TABLE_MODULE_CODE: &str = r#" -use spacetimedb::{log, duration, ReducerContext, Table, Timestamp}; - -#[spacetimedb::table(name = scheduled_table, public, scheduled(my_reducer, at = sched_at))] -pub struct ScheduledTable { - #[primary_key] - #[auto_inc] - scheduled_id: u64, - sched_at: spacetimedb::ScheduleAt, - prev: Timestamp, -} - -#[spacetimedb::reducer] -fn schedule_reducer(ctx: &ReducerContext) { - ctx.db.scheduled_table().insert(ScheduledTable { prev: Timestamp::from_micros_since_unix_epoch(0), scheduled_id: 2, sched_at: Timestamp::from_micros_since_unix_epoch(0).into(), }); -} - -#[spacetimedb::reducer] -fn schedule_repeated_reducer(ctx: &ReducerContext) { - ctx.db.scheduled_table().insert(ScheduledTable { prev: Timestamp::from_micros_since_unix_epoch(0), scheduled_id: 1, sched_at: duration!(100ms).into(), }); -} - -#[spacetimedb::reducer] -pub fn my_reducer(ctx: &ReducerContext, arg: ScheduledTable) { - log::info!("Invoked: ts={:?}, delta={:?}", ctx.timestamp, ctx.timestamp.duration_since(arg.prev)); -} -"#; - /// Test deploying a module with a scheduled reducer and check if client receives /// subscription update for scheduled table entry and deletion of reducer once it ran #[test] fn test_scheduled_table_subscription() { let test = Smoketest::builder() - .module_code(SUBSCRIBE_SCHEDULED_TABLE_MODULE_CODE) + .precompiled_module("schedule-subscribe") .build(); // Call a reducer to schedule a reducer (runs immediately since timestamp is 0) @@ -116,7 +48,7 @@ fn test_scheduled_table_subscription() { #[test] fn test_scheduled_table_subscription_repeated_reducer() { let test = Smoketest::builder() - .module_code(SUBSCRIBE_SCHEDULED_TABLE_MODULE_CODE) + .precompiled_module("schedule-subscribe") .build(); // Call a reducer to schedule a repeated reducer @@ -135,29 +67,10 @@ fn test_scheduled_table_subscription_repeated_reducer() { ); } -const VOLATILE_NONATOMIC_MODULE_CODE: &str = r#" -use spacetimedb::{ReducerContext, Table}; - -#[spacetimedb::table(name = my_table, public)] -pub struct MyTable { - x: String, -} - -#[spacetimedb::reducer] -fn do_schedule(_ctx: &ReducerContext) { - spacetimedb::volatile_nonatomic_schedule_immediate!(do_insert("hello".to_owned())); -} - -#[spacetimedb::reducer] -fn do_insert(ctx: &ReducerContext, x: String) { - ctx.db.my_table().insert(MyTable { x }); -} -"#; - /// Check that volatile_nonatomic_schedule_immediate works #[test] fn test_volatile_nonatomic_schedule_immediate() { - let test = Smoketest::builder().module_code(VOLATILE_NONATOMIC_MODULE_CODE).build(); + let test = Smoketest::builder().precompiled_module("schedule-volatile").build(); // Insert directly first test.call("do_insert", &[r#""yay!""#]).unwrap(); diff --git a/crates/smoketests/tests/smoketests/sql.rs b/crates/smoketests/tests/smoketests/sql.rs index a908196aeab..86aad3a6375 100644 --- a/crates/smoketests/tests/smoketests/sql.rs +++ b/crates/smoketests/tests/smoketests/sql.rs @@ -2,135 +2,10 @@ use spacetimedb_smoketests::Smoketest; -const SQL_FORMAT_MODULE_CODE: &str = r#" -use spacetimedb::sats::{i256, u256}; -use spacetimedb::{ConnectionId, Identity, ReducerContext, Table, Timestamp, TimeDuration, SpacetimeType, Uuid}; - -#[derive(Copy, Clone)] -#[spacetimedb::table(name = t_ints)] -pub struct TInts { - i8: i8, - i16: i16, - i32: i32, - i64: i64, - i128: i128, - i256: i256, -} - -#[spacetimedb::table(name = t_ints_tuple)] -pub struct TIntsTuple { - tuple: TInts, -} - -#[derive(Copy, Clone)] -#[spacetimedb::table(name = t_uints)] -pub struct TUints { - u8: u8, - u16: u16, - u32: u32, - u64: u64, - u128: u128, - u256: u256, -} - -#[spacetimedb::table(name = t_uints_tuple)] -pub struct TUintsTuple { - tuple: TUints, -} - -#[derive(Clone)] -#[spacetimedb::table(name = t_others)] -pub struct TOthers { - bool: bool, - f32: f32, - f64: f64, - str: String, - bytes: Vec, - identity: Identity, - connection_id: ConnectionId, - timestamp: Timestamp, - duration: TimeDuration, - uuid: Uuid, -} - -#[spacetimedb::table(name = t_others_tuple)] -pub struct TOthersTuple { - tuple: TOthers -} - -#[derive(SpacetimeType, Debug, Clone, Copy)] -pub enum Action { - Inactive, - Active, -} - -#[derive(Clone)] -#[spacetimedb::table(name = t_enums)] -pub struct TEnums { - bool_opt: Option, - bool_result: Result, - action: Action, -} - -#[spacetimedb::table(name = t_enums_tuple)] -pub struct TEnumsTuple { - tuple: TEnums, -} - -#[spacetimedb::reducer] -pub fn test(ctx: &ReducerContext) { - let tuple = TInts { - i8: -25, - i16: -3224, - i32: -23443, - i64: -2344353, - i128: -234434897853, - i256: (-234434897853i128).into(), - }; - ctx.db.t_ints().insert(tuple); - ctx.db.t_ints_tuple().insert(TIntsTuple { tuple }); - - let tuple = TUints { - u8: 105, - u16: 1050, - u32: 83892, - u64: 48937498, - u128: 4378528978889, - u256: 4378528978889u128.into(), - }; - ctx.db.t_uints().insert(tuple); - ctx.db.t_uints_tuple().insert(TUintsTuple { tuple }); - - let tuple = TOthers { - bool: true, - f32: 594806.58906, - f64: -3454353.345389043278459, - str: "This is spacetimedb".to_string(), - bytes: vec!(1, 2, 3, 4, 5, 6, 7), - identity: Identity::ONE, - connection_id: ConnectionId::ZERO, - timestamp: Timestamp::UNIX_EPOCH, - duration: TimeDuration::ZERO, - uuid: Uuid::NIL, - }; - ctx.db.t_others().insert(tuple.clone()); - ctx.db.t_others_tuple().insert(TOthersTuple { tuple }); - - let tuple = TEnums { - bool_opt: Some(true), - bool_result: Ok(false), - action: Action::Active, - }; - - ctx.db.t_enums().insert(tuple.clone()); - ctx.db.t_enums_tuple().insert(TEnumsTuple { tuple }); -} -"#; - /// This test is designed to test the format of the output of sql queries #[test] fn test_sql_format() { - let test = Smoketest::builder().module_code(SQL_FORMAT_MODULE_CODE).build(); + let test = Smoketest::builder().precompiled_module("sql-format").build(); test.call("test", &[]).unwrap(); diff --git a/crates/smoketests/tests/smoketests/views.rs b/crates/smoketests/tests/smoketests/views.rs index d3cf7c4eadf..31fc92412c6 100644 --- a/crates/smoketests/tests/smoketests/views.rs +++ b/crates/smoketests/tests/smoketests/views.rs @@ -2,28 +2,10 @@ use spacetimedb_smoketests::Smoketest; -const MODULE_CODE_VIEWS: &str = r#" -use spacetimedb::ViewContext; - -#[derive(Copy, Clone)] -#[spacetimedb::table(name = player_state)] -pub struct PlayerState { - #[primary_key] - id: u64, - #[index(btree)] - level: u64, -} - -#[spacetimedb::view(name = player, public)] -pub fn player(ctx: &ViewContext) -> Option { - ctx.db.player_state().id().find(0u64) -} -"#; - /// Tests that views populate the st_view_* system tables #[test] fn test_st_view_tables() { - let test = Smoketest::builder().module_code(MODULE_CODE_VIEWS).build(); + let test = Smoketest::builder().precompiled_module("views-basic").build(); test.assert_sql( "SELECT * FROM st_view", @@ -101,72 +83,10 @@ fn test_fail_publish_wrong_return_type() { ); } -const MODULE_CODE_SQL_VIEWS: &str = r#" -use spacetimedb::{AnonymousViewContext, ReducerContext, Table, ViewContext}; - -#[derive(Copy, Clone)] -#[spacetimedb::table(name = player_state)] -#[spacetimedb::table(name = player_level)] -pub struct PlayerState { - #[primary_key] - id: u64, - #[index(btree)] - level: u64, -} - -#[derive(Clone)] -#[spacetimedb::table(name = player_info, index(name=age_level_index, btree(columns = [age, level])))] -pub struct PlayerInfo { - #[primary_key] - id: u64, - age: u64, - level: u64, -} - -#[spacetimedb::reducer] -pub fn add_player_level(ctx: &ReducerContext, id: u64, level: u64) { - ctx.db.player_level().insert(PlayerState { id, level }); -} - -#[spacetimedb::view(name = my_player_and_level, public)] -pub fn my_player_and_level(ctx: &AnonymousViewContext) -> Option { - ctx.db.player_level().id().find(0) -} - -#[spacetimedb::view(name = player_and_level, public)] -pub fn player_and_level(ctx: &AnonymousViewContext) -> Vec { - ctx.db.player_level().level().filter(2u64).collect() -} - -#[spacetimedb::view(name = player, public)] -pub fn player(ctx: &ViewContext) -> Option { - log::info!("player view called"); - ctx.db.player_state().id().find(42) -} - -#[spacetimedb::view(name = player_none, public)] -pub fn player_none(_ctx: &ViewContext) -> Option { - None -} - -#[spacetimedb::view(name = player_vec, public)] -pub fn player_vec(ctx: &ViewContext) -> Vec { - let first = ctx.db.player_state().id().find(42).unwrap(); - let second = PlayerState { id: 7, level: 3 }; - vec![first, second] -} - -#[spacetimedb::view(name = player_info_multi_index, public)] -pub fn player_info_view(ctx: &ViewContext) -> Option { - log::info!("player_info called"); - ctx.db.player_info().age_level_index().filter((25u64, 7u64)).next() -} -"#; - /// Tests that views can be queried over HTTP SQL #[test] fn test_http_sql_views() { - let test = Smoketest::builder().module_code(MODULE_CODE_SQL_VIEWS).build(); + let test = Smoketest::builder().precompiled_module("views-sql").build(); // Insert initial data test.sql("INSERT INTO player_state (id, level) VALUES (42, 7)").unwrap(); @@ -196,7 +116,7 @@ fn test_http_sql_views() { /// Tests that anonymous views are updated for reducers #[test] fn test_query_anonymous_view_reducer() { - let test = Smoketest::builder().module_code(MODULE_CODE_SQL_VIEWS).build(); + let test = Smoketest::builder().precompiled_module("views-sql").build(); test.call("add_player_level", &["0", "1"]).unwrap(); test.call("add_player_level", &["1", "2"]).unwrap(); diff --git a/tools/xtask-smoketest/src/main.rs b/tools/xtask-smoketest/src/main.rs index b47214c571d..10f403b559a 100644 --- a/tools/xtask-smoketest/src/main.rs +++ b/tools/xtask-smoketest/src/main.rs @@ -131,6 +131,34 @@ spacetimedb = {{ path = "{}" }} Ok(()) } +fn build_precompiled_modules() -> Result<()> { + let workspace_root = env::current_dir()?; + let modules_dir = workspace_root.join("crates/smoketests/modules"); + + // Check if the modules workspace exists + if !modules_dir.join("Cargo.toml").exists() { + eprintln!("Skipping pre-compiled modules (workspace not found).\n"); + return Ok(()); + } + + eprintln!("Building pre-compiled smoketest modules..."); + + let status = Command::new("cargo") + .args([ + "build", + "--workspace", + "--release", + "--target", + "wasm32-unknown-unknown", + ]) + .current_dir(&modules_dir) + .status()?; + + ensure!(status.success(), "Failed to build pre-compiled modules"); + eprintln!("Pre-compiled modules built.\n"); + Ok(()) +} + /// Default parallelism for smoketests. /// Limited to avoid cargo build lock contention when many tests run simultaneously. const DEFAULT_PARALLELISM: &str = "8"; @@ -142,7 +170,10 @@ fn run_smoketest(args: Vec) -> Result<()> { // 2. Warm the WASM dependency cache (single process, no race) warmup_wasm_cache()?; - // 3. Detect whether to use nextest or cargo test + // 3. Build pre-compiled modules (if available) + build_precompiled_modules()?; + + // 4. Detect whether to use nextest or cargo test let use_nextest = Command::new("cargo") .args(["nextest", "--version"]) .stdout(Stdio::null()) @@ -151,7 +182,7 @@ fn run_smoketest(args: Vec) -> Result<()> { .map(|s| s.success()) .unwrap_or(false); - // 4. Run tests with appropriate runner + // 5. Run tests with appropriate runner let status = if use_nextest { eprintln!("Running smoketests with cargo nextest...\n"); let mut cmd = Command::new("cargo"); From 4ad31127161e1c554fb6cc86fe591b8db575b927 Mon Sep 17 00:00:00 2001 From: = Date: Sun, 25 Jan 2026 21:14:38 -0500 Subject: [PATCH 043/118] Switch smoketests to release mode and remove redundant warmup - Build CLI/standalone in release mode for faster test execution - Run tests in release mode (faster SpacetimeDB server) - Remove redundant WASM cache warmup (precompiled modules already warm it) - Increase default parallelism to 16 (optimal based on benchmarks) This reduces fresh build time by ~16 seconds and test execution time by ~5x compared to debug mode. --- tools/xtask-smoketest/Cargo.toml | 1 - tools/xtask-smoketest/src/main.rs | 78 ++++--------------------------- 2 files changed, 10 insertions(+), 69 deletions(-) diff --git a/tools/xtask-smoketest/Cargo.toml b/tools/xtask-smoketest/Cargo.toml index d7f7cdd2774..0af68c2d6f6 100644 --- a/tools/xtask-smoketest/Cargo.toml +++ b/tools/xtask-smoketest/Cargo.toml @@ -6,4 +6,3 @@ edition.workspace = true [dependencies] anyhow.workspace = true clap.workspace = true -tempfile.workspace = true diff --git a/tools/xtask-smoketest/src/main.rs b/tools/xtask-smoketest/src/main.rs index 10f403b559a..a4895d7fc79 100644 --- a/tools/xtask-smoketest/src/main.rs +++ b/tools/xtask-smoketest/src/main.rs @@ -1,7 +1,6 @@ use anyhow::{ensure, Result}; use clap::{Parser, Subcommand}; use std::env; -use std::fs; use std::process::{Command, Stdio}; /// SpacetimeDB development tasks @@ -55,10 +54,10 @@ fn main() -> Result<()> { } fn build_binaries() -> Result<()> { - eprintln!("Building spacetimedb-cli and spacetimedb-standalone..."); + eprintln!("Building spacetimedb-cli and spacetimedb-standalone (release)..."); let mut cmd = Command::new("cargo"); - cmd.args(["build", "-p", "spacetimedb-cli", "-p", "spacetimedb-standalone"]); + cmd.args(["build", "--release", "-p", "spacetimedb-cli", "-p", "spacetimedb-standalone"]); // Remove cargo/rust env vars that could cause fingerprint mismatches // when the test later runs cargo build from a different environment @@ -77,60 +76,6 @@ fn build_binaries() -> Result<()> { Ok(()) } -fn warmup_wasm_cache() -> Result<()> { - eprintln!("Warming WASM dependency cache..."); - - let workspace_root = env::current_dir()?; - let target_dir = workspace_root.join("target/smoketest-modules"); - fs::create_dir_all(&target_dir)?; - - let temp_dir = tempfile::tempdir()?; - - // Write minimal Cargo.toml that depends on spacetimedb bindings - let bindings_path = workspace_root.join("crates/bindings"); - let cargo_toml = format!( - r#"[package] -name = "warmup" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -spacetimedb = {{ path = "{}" }} -"#, - bindings_path.display().to_string().replace('\\', "/") - ); - fs::write(temp_dir.path().join("Cargo.toml"), cargo_toml)?; - - // Copy rust-toolchain.toml if it exists - let toolchain_src = workspace_root.join("rust-toolchain.toml"); - if toolchain_src.exists() { - fs::copy(&toolchain_src, temp_dir.path().join("rust-toolchain.toml"))?; - } - - // Write minimal lib.rs - fs::create_dir_all(temp_dir.path().join("src"))?; - fs::write(temp_dir.path().join("src/lib.rs"), "")?; - - // Build to warm the cache - let status = Command::new("cargo") - .args([ - "build", - "--target", - "wasm32-unknown-unknown", - "--release", - ]) - .env("CARGO_TARGET_DIR", &target_dir) - .current_dir(temp_dir.path()) - .status()?; - - ensure!(status.success(), "Failed to warm WASM cache"); - eprintln!("WASM cache warmed.\n"); - Ok(()) -} - fn build_precompiled_modules() -> Result<()> { let workspace_root = env::current_dir()?; let modules_dir = workspace_root.join("crates/smoketests/modules"); @@ -160,17 +105,14 @@ fn build_precompiled_modules() -> Result<()> { } /// Default parallelism for smoketests. -/// Limited to avoid cargo build lock contention when many tests run simultaneously. -const DEFAULT_PARALLELISM: &str = "8"; +/// 16 was found to be optimal - higher values cause OS scheduler overhead. +const DEFAULT_PARALLELISM: &str = "16"; fn run_smoketest(args: Vec) -> Result<()> { // 1. Build binaries first (single process, no race) build_binaries()?; - // 2. Warm the WASM dependency cache (single process, no race) - warmup_wasm_cache()?; - - // 3. Build pre-compiled modules (if available) + // 2. Build pre-compiled modules (this also warms the WASM dependency cache) build_precompiled_modules()?; // 4. Detect whether to use nextest or cargo test @@ -182,11 +124,11 @@ fn run_smoketest(args: Vec) -> Result<()> { .map(|s| s.success()) .unwrap_or(false); - // 5. Run tests with appropriate runner + // 5. Run tests with appropriate runner (release mode for faster execution) let status = if use_nextest { - eprintln!("Running smoketests with cargo nextest...\n"); + eprintln!("Running smoketests with cargo nextest (release)...\n"); let mut cmd = Command::new("cargo"); - cmd.args(["nextest", "run", "-p", "spacetimedb-smoketests", "--no-fail-fast"]); + cmd.args(["nextest", "run", "--release", "-p", "spacetimedb-smoketests", "--no-fail-fast"]); // Set default parallelism if user didn't specify -j if !args.iter().any(|a| a == "-j" || a.starts_with("-j") || a.starts_with("--jobs")) { @@ -195,9 +137,9 @@ fn run_smoketest(args: Vec) -> Result<()> { cmd.args(&args).status()? } else { - eprintln!("Running smoketests with cargo test...\n"); + eprintln!("Running smoketests with cargo test (release)...\n"); Command::new("cargo") - .args(["test", "-p", "spacetimedb-smoketests"]) + .args(["test", "--release", "-p", "spacetimedb-smoketests"]) .args(&args) .status()? }; From db95efd38cb07aabe8115d4c0f511836d4a99ea1 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Sun, 25 Jan 2026 21:27:37 -0500 Subject: [PATCH 044/118] Ran cargo fmt --- Cargo.lock | 1 - crates/guard/src/lib.rs | 9 +- crates/smoketests/src/lib.rs | 8 +- crates/smoketests/src/modules.rs | 114 ++++++++++++++---- .../tests/smoketests/add_remove_index.rs | 5 +- .../smoketests/tests/smoketests/auto_inc.rs | 24 +--- .../smoketests/client_connection_errors.rs | 8 +- .../tests/smoketests/delete_database.rs | 5 +- crates/smoketests/tests/smoketests/modules.rs | 5 +- .../smoketests/tests/smoketests/namespaces.rs | 10 +- .../tests/smoketests/new_user_flow.rs | 5 +- crates/smoketests/tests/smoketests/restart.rs | 4 +- .../tests/smoketests/schedule_reducer.rs | 8 +- tools/xtask-smoketest/src/main.rs | 23 +++- 14 files changed, 148 insertions(+), 81 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 768bd8d1639..cf1dff65ca8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11039,7 +11039,6 @@ version = "0.1.0" dependencies = [ "anyhow", "clap 4.5.50", - "tempfile", ] [[package]] diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 22246fbbaed..e64c5aa153c 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -180,8 +180,7 @@ impl SpacetimeDbGuard { let args = ["start", "--data-dir", &data_dir_str, "--listen-addr", &address]; let cmd = Command::new("spacetime"); - let (child, logs, reader_threads) = - Self::spawn_child(cmd, env!("CARGO_MANIFEST_DIR"), &args, spawn_id); + let (child, logs, reader_threads) = Self::spawn_child(cmd, env!("CARGO_MANIFEST_DIR"), &args, spawn_id); eprintln!("[SPAWN-{:03}] Waiting for listen address", spawn_id); let listen_addr = wait_for_listen_addr(&logs, Duration::from_secs(10), spawn_id).unwrap_or_else(|| { @@ -254,8 +253,7 @@ impl SpacetimeDbGuard { sleep(Duration::from_millis(100)); eprintln!("[RESTART-{:03}] Spawning new server", spawn_id); - let (child, logs, host_url, reader_threads) = - Self::spawn_server(&self.data_dir, self.pg_port, spawn_id); + let (child, logs, host_url, reader_threads) = Self::spawn_server(&self.data_dir, self.pg_port, spawn_id); eprintln!( "[RESTART-{:03}] New server ready, pid={}, url={}", spawn_id, @@ -336,8 +334,7 @@ impl SpacetimeDbGuard { eprintln!("[SPAWN-{:03}] Spawning child process", spawn_id); let cmd = Command::new(&cli_path); - let (child, logs, reader_threads) = - Self::spawn_child(cmd, workspace_root.to_str().unwrap(), &args, spawn_id); + let (child, logs, reader_threads) = Self::spawn_child(cmd, workspace_root.to_str().unwrap(), &args, spawn_id); eprintln!("[SPAWN-{:03}] Child spawned pid={}", spawn_id, child.id()); // Wait for the server to be ready diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 79bf7ded77a..065648e074c 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -160,7 +160,6 @@ pub fn have_pnpm() -> bool { }) } - /// Parse code blocks from quickstart markdown documentation. /// Extracts code blocks with the specified language tag. /// @@ -622,8 +621,7 @@ log = "0.4" "#, self.module_name, bindings_path_str ); - fs::write(self.project_dir.path().join("Cargo.toml"), cargo_toml) - .context("Failed to write Cargo.toml")?; + fs::write(self.project_dir.path().join("Cargo.toml"), cargo_toml).context("Failed to write Cargo.toml")?; // Copy rust-toolchain.toml let toolchain_src = workspace_root.join("rust-toolchain.toml"); @@ -734,9 +732,7 @@ log = "0.4" // Construct the wasm path using the unique module name let wasm_filename = format!("{}.wasm", self.module_name); - let wasm_path = target_dir - .join("wasm32-unknown-unknown/release") - .join(&wasm_filename); + let wasm_path = target_dir.join("wasm32-unknown-unknown/release").join(&wasm_filename); wasm_path.to_str().unwrap().to_string() }; diff --git a/crates/smoketests/src/modules.rs b/crates/smoketests/src/modules.rs index 33eb6d94ee6..a387a5dd257 100644 --- a/crates/smoketests/src/modules.rs +++ b/crates/smoketests/src/modules.rs @@ -24,10 +24,13 @@ static REGISTRY: OnceLock> = OnceLock::new(); /// to the nested workspace yet. pub fn precompiled_module(name: &str) -> PathBuf { let registry = REGISTRY.get_or_init(build_registry); - registry - .get(name) - .cloned() - .unwrap_or_else(|| panic!("Unknown precompiled module: '{}'. Available modules: {:?}", name, registry.keys().collect::>())) + registry.get(name).cloned().unwrap_or_else(|| { + panic!( + "Unknown precompiled module: '{}'. Available modules: {:?}", + name, + registry.keys().collect::>() + ) + }) } /// Returns true if pre-compiled modules are available. @@ -42,8 +45,7 @@ pub fn precompiled_modules_available() -> bool { /// Returns the target directory where pre-compiled WASM modules are stored. fn modules_target_dir() -> PathBuf { - workspace_root() - .join("crates/smoketests/modules/target/wasm32-unknown-unknown/release") + workspace_root().join("crates/smoketests/modules/target/wasm32-unknown-unknown/release") } /// Builds the registry mapping module names to WASM paths. @@ -63,11 +65,20 @@ fn build_registry() -> HashMap<&'static str, PathBuf> { // Security and permissions reg.insert("rls", target.join("smoketest_module_rls.wasm")); - reg.insert("permissions-private", target.join("smoketest_module_permissions_private.wasm")); - reg.insert("permissions-lifecycle", target.join("smoketest_module_permissions_lifecycle.wasm")); + reg.insert( + "permissions-private", + target.join("smoketest_module_permissions_private.wasm"), + ); + reg.insert( + "permissions-lifecycle", + target.join("smoketest_module_permissions_lifecycle.wasm"), + ); // Call/procedure tests - reg.insert("call-reducer-procedure", target.join("smoketest_module_call_reducer_procedure.wasm")); + reg.insert( + "call-reducer-procedure", + target.join("smoketest_module_call_reducer_procedure.wasm"), + ); reg.insert("call-empty", target.join("smoketest_module_call_empty.wasm")); // SQL format tests @@ -76,18 +87,33 @@ fn build_registry() -> HashMap<&'static str, PathBuf> { // Scheduled reducer tests reg.insert("schedule-cancel", target.join("smoketest_module_schedule_cancel.wasm")); - reg.insert("schedule-subscribe", target.join("smoketest_module_schedule_subscribe.wasm")); - reg.insert("schedule-volatile", target.join("smoketest_module_schedule_volatile.wasm")); + reg.insert( + "schedule-subscribe", + target.join("smoketest_module_schedule_subscribe.wasm"), + ); + reg.insert( + "schedule-volatile", + target.join("smoketest_module_schedule_volatile.wasm"), + ); // Module lifecycle tests reg.insert("describe", target.join("smoketest_module_describe.wasm")); reg.insert("modules-basic", target.join("smoketest_module_modules_basic.wasm")); // modules-breaking is intentionally broken, not precompiled - reg.insert("modules-add-table", target.join("smoketest_module_modules_add_table.wasm")); + reg.insert( + "modules-add-table", + target.join("smoketest_module_modules_add_table.wasm"), + ); // Index tests - reg.insert("add-remove-index", target.join("smoketest_module_add_remove_index.wasm")); - reg.insert("add-remove-index-indexed", target.join("smoketest_module_add_remove_index_indexed.wasm")); + reg.insert( + "add-remove-index", + target.join("smoketest_module_add_remove_index.wasm"), + ); + reg.insert( + "add-remove-index-indexed", + target.join("smoketest_module_add_remove_index_indexed.wasm"), + ); // Panic/error handling reg.insert("panic", target.join("smoketest_module_panic.wasm")); @@ -95,29 +121,65 @@ fn build_registry() -> HashMap<&'static str, PathBuf> { // Restart tests reg.insert("restart-person", target.join("smoketest_module_restart_person.wasm")); - reg.insert("restart-connected-client", target.join("smoketest_module_restart_connected_client.wasm")); + reg.insert( + "restart-connected-client", + target.join("smoketest_module_restart_connected_client.wasm"), + ); // Connection tests - reg.insert("connect-disconnect", target.join("smoketest_module_connect_disconnect.wasm")); + reg.insert( + "connect-disconnect", + target.join("smoketest_module_connect_disconnect.wasm"), + ); reg.insert("confirmed-reads", target.join("smoketest_module_confirmed_reads.wasm")); reg.insert("delete-database", target.join("smoketest_module_delete_database.wasm")); - reg.insert("client-connection-reject", target.join("smoketest_module_client_connection_reject.wasm")); - reg.insert("client-connection-disconnect-panic", target.join("smoketest_module_client_connection_disconnect_panic.wasm")); + reg.insert( + "client-connection-reject", + target.join("smoketest_module_client_connection_reject.wasm"), + ); + reg.insert( + "client-connection-disconnect-panic", + target.join("smoketest_module_client_connection_disconnect_panic.wasm"), + ); // Misc tests reg.insert("namespaces", target.join("smoketest_module_namespaces.wasm")); reg.insert("new-user-flow", target.join("smoketest_module_new_user_flow.wasm")); - reg.insert("module-nested-op", target.join("smoketest_module_module_nested_op.wasm")); + reg.insert( + "module-nested-op", + target.join("smoketest_module_module_nested_op.wasm"), + ); // fail-initial-publish-broken is intentionally broken, not precompiled - reg.insert("fail-initial-publish-fixed", target.join("smoketest_module_fail_initial_publish_fixed.wasm")); + reg.insert( + "fail-initial-publish-fixed", + target.join("smoketest_module_fail_initial_publish_fixed.wasm"), + ); // Auto-increment tests (parameterized variants) - reg.insert("autoinc-basic-u32", target.join("smoketest_module_autoinc_basic_u32.wasm")); - reg.insert("autoinc-basic-u64", target.join("smoketest_module_autoinc_basic_u64.wasm")); - reg.insert("autoinc-basic-i32", target.join("smoketest_module_autoinc_basic_i32.wasm")); - reg.insert("autoinc-basic-i64", target.join("smoketest_module_autoinc_basic_i64.wasm")); - reg.insert("autoinc-unique-u64", target.join("smoketest_module_autoinc_unique_u64.wasm")); - reg.insert("autoinc-unique-i64", target.join("smoketest_module_autoinc_unique_i64.wasm")); + reg.insert( + "autoinc-basic-u32", + target.join("smoketest_module_autoinc_basic_u32.wasm"), + ); + reg.insert( + "autoinc-basic-u64", + target.join("smoketest_module_autoinc_basic_u64.wasm"), + ); + reg.insert( + "autoinc-basic-i32", + target.join("smoketest_module_autoinc_basic_i32.wasm"), + ); + reg.insert( + "autoinc-basic-i64", + target.join("smoketest_module_autoinc_basic_i64.wasm"), + ); + reg.insert( + "autoinc-unique-u64", + target.join("smoketest_module_autoinc_unique_u64.wasm"), + ); + reg.insert( + "autoinc-unique-i64", + target.join("smoketest_module_autoinc_unique_i64.wasm"), + ); reg } diff --git a/crates/smoketests/tests/smoketests/add_remove_index.rs b/crates/smoketests/tests/smoketests/add_remove_index.rs index ee0e8fa609d..22484c4834c 100644 --- a/crates/smoketests/tests/smoketests/add_remove_index.rs +++ b/crates/smoketests/tests/smoketests/add_remove_index.rs @@ -11,7 +11,10 @@ const JOIN_QUERY: &str = "select t1.* from t1 join t2 on t1.id = t2.id where t2. /// and the unindexed versions should reject subscriptions. #[test] fn test_add_then_remove_index() { - let mut test = Smoketest::builder().precompiled_module("add-remove-index").autopublish(false).build(); + let mut test = Smoketest::builder() + .precompiled_module("add-remove-index") + .autopublish(false) + .build(); let name = format!("test-db-{}", std::process::id()); diff --git a/crates/smoketests/tests/smoketests/auto_inc.rs b/crates/smoketests/tests/smoketests/auto_inc.rs index d71d0ce2ba7..03800e4363e 100644 --- a/crates/smoketests/tests/smoketests/auto_inc.rs +++ b/crates/smoketests/tests/smoketests/auto_inc.rs @@ -7,9 +7,7 @@ use spacetimedb_smoketests::Smoketest; #[test] fn test_autoinc_u32() { - let test = Smoketest::builder() - .precompiled_module("autoinc-basic-u32") - .build(); + let test = Smoketest::builder().precompiled_module("autoinc-basic-u32").build(); test.call("add_u32", &[r#""Robert""#, "1"]).unwrap(); test.call("add_u32", &[r#""Julie""#, "2"]).unwrap(); @@ -41,9 +39,7 @@ fn test_autoinc_u32() { #[test] fn test_autoinc_u64() { - let test = Smoketest::builder() - .precompiled_module("autoinc-basic-u64") - .build(); + let test = Smoketest::builder().precompiled_module("autoinc-basic-u64").build(); test.call("add_u64", &[r#""Robert""#, "1"]).unwrap(); test.call("add_u64", &[r#""Julie""#, "2"]).unwrap(); @@ -75,9 +71,7 @@ fn test_autoinc_u64() { #[test] fn test_autoinc_i32() { - let test = Smoketest::builder() - .precompiled_module("autoinc-basic-i32") - .build(); + let test = Smoketest::builder().precompiled_module("autoinc-basic-i32").build(); test.call("add_i32", &[r#""Robert""#, "1"]).unwrap(); test.call("add_i32", &[r#""Julie""#, "2"]).unwrap(); @@ -109,9 +103,7 @@ fn test_autoinc_i32() { #[test] fn test_autoinc_i64() { - let test = Smoketest::builder() - .precompiled_module("autoinc-basic-i64") - .build(); + let test = Smoketest::builder().precompiled_module("autoinc-basic-i64").build(); test.call("add_i64", &[r#""Robert""#, "1"]).unwrap(); test.call("add_i64", &[r#""Julie""#, "2"]).unwrap(); @@ -143,9 +135,7 @@ fn test_autoinc_i64() { #[test] fn test_autoinc_unique_u64() { - let test = Smoketest::builder() - .precompiled_module("autoinc-unique-u64") - .build(); + let test = Smoketest::builder().precompiled_module("autoinc-unique-u64").build(); // Insert Robert with explicit id 2 test.call("update_u64", &[r#""Robert""#, "2"]).unwrap(); @@ -182,9 +172,7 @@ fn test_autoinc_unique_u64() { #[test] fn test_autoinc_unique_i64() { - let test = Smoketest::builder() - .precompiled_module("autoinc-unique-i64") - .build(); + let test = Smoketest::builder().precompiled_module("autoinc-unique-i64").build(); // Insert Robert with explicit id 2 test.call("update_i64", &[r#""Robert""#, "2"]).unwrap(); diff --git a/crates/smoketests/tests/smoketests/client_connection_errors.rs b/crates/smoketests/tests/smoketests/client_connection_errors.rs index 62cc1e896c3..f8f28fda385 100644 --- a/crates/smoketests/tests/smoketests/client_connection_errors.rs +++ b/crates/smoketests/tests/smoketests/client_connection_errors.rs @@ -5,7 +5,9 @@ use spacetimedb_smoketests::Smoketest; /// Test that client_connected returning an error rejects the connection #[test] fn test_client_connected_error_rejects_connection() { - let test = Smoketest::builder().precompiled_module("client-connection-reject").build(); + let test = Smoketest::builder() + .precompiled_module("client-connection-reject") + .build(); // Subscribe should fail because client_connected returns an error let result = test.subscribe(&["SELECT * FROM all_u8s"], 0); @@ -30,7 +32,9 @@ fn test_client_connected_error_rejects_connection() { /// Test that client_disconnected panicking still cleans up the st_client row #[test] fn test_client_disconnected_error_still_deletes_st_client() { - let test = Smoketest::builder().precompiled_module("client-connection-disconnect-panic").build(); + let test = Smoketest::builder() + .precompiled_module("client-connection-disconnect-panic") + .build(); // Subscribe should succeed (client_connected returns Ok) let result = test.subscribe(&["SELECT * FROM all_u8s"], 0); diff --git a/crates/smoketests/tests/smoketests/delete_database.rs b/crates/smoketests/tests/smoketests/delete_database.rs index 014bf0cf35a..86cd246ff92 100644 --- a/crates/smoketests/tests/smoketests/delete_database.rs +++ b/crates/smoketests/tests/smoketests/delete_database.rs @@ -9,7 +9,10 @@ use std::time::Duration; /// producing update events. #[test] fn test_delete_database() { - let mut test = Smoketest::builder().precompiled_module("delete-database").autopublish(false).build(); + let mut test = Smoketest::builder() + .precompiled_module("delete-database") + .autopublish(false) + .build(); let name = format!("test-db-{}", std::process::id()); test.publish_module_named(&name, false).unwrap(); diff --git a/crates/smoketests/tests/smoketests/modules.rs b/crates/smoketests/tests/smoketests/modules.rs index 0b03f50c5b6..6609eefe283 100644 --- a/crates/smoketests/tests/smoketests/modules.rs +++ b/crates/smoketests/tests/smoketests/modules.rs @@ -17,7 +17,10 @@ pub struct Person { /// Test publishing a module without the --delete-data option #[test] fn test_module_update() { - let mut test = Smoketest::builder().precompiled_module("modules-basic").autopublish(false).build(); + let mut test = Smoketest::builder() + .precompiled_module("modules-basic") + .autopublish(false) + .build(); let name = format!("test-db-{}", std::process::id()); diff --git a/crates/smoketests/tests/smoketests/namespaces.rs b/crates/smoketests/tests/smoketests/namespaces.rs index b980f0b21c5..fa02b338a8a 100644 --- a/crates/smoketests/tests/smoketests/namespaces.rs +++ b/crates/smoketests/tests/smoketests/namespaces.rs @@ -61,10 +61,7 @@ fn count_matches(dir: &Path, needle: &str) -> usize { /// Ensure that the default namespace is working properly #[test] fn test_spacetimedb_ns_csharp() { - let test = Smoketest::builder() - .module_code(MODULE_CODE) - .autopublish(false) - .build(); + let test = Smoketest::builder().module_code(MODULE_CODE).autopublish(false).build(); let tmpdir = tempfile::tempdir().expect("Failed to create temp dir"); let project_path = test.project_dir.path().to_str().unwrap(); @@ -96,10 +93,7 @@ fn test_spacetimedb_ns_csharp() { /// Ensure that when a custom namespace is specified on the command line, it actually gets used in generation #[test] fn test_custom_ns_csharp() { - let test = Smoketest::builder() - .module_code(MODULE_CODE) - .autopublish(false) - .build(); + let test = Smoketest::builder().module_code(MODULE_CODE).autopublish(false).build(); let tmpdir = tempfile::tempdir().expect("Failed to create temp dir"); let project_path = test.project_dir.path().to_str().unwrap(); diff --git a/crates/smoketests/tests/smoketests/new_user_flow.rs b/crates/smoketests/tests/smoketests/new_user_flow.rs index ddd522f014e..41f2a39a70d 100644 --- a/crates/smoketests/tests/smoketests/new_user_flow.rs +++ b/crates/smoketests/tests/smoketests/new_user_flow.rs @@ -5,7 +5,10 @@ use spacetimedb_smoketests::Smoketest; /// Test the entirety of the new user flow. #[test] fn test_new_user_flow() { - let mut test = Smoketest::builder().precompiled_module("new-user-flow").autopublish(false).build(); + let mut test = Smoketest::builder() + .precompiled_module("new-user-flow") + .autopublish(false) + .build(); // Create a new identity and publish test.new_identity().unwrap(); diff --git a/crates/smoketests/tests/smoketests/restart.rs b/crates/smoketests/tests/smoketests/restart.rs index 948220fd320..2a30610fa7d 100644 --- a/crates/smoketests/tests/smoketests/restart.rs +++ b/crates/smoketests/tests/smoketests/restart.rs @@ -59,7 +59,9 @@ fn test_restart_sql() { /// Test clients are auto-disconnected on restart. #[test] fn test_restart_auto_disconnect() { - let mut test = Smoketest::builder().precompiled_module("restart-connected-client").build(); + let mut test = Smoketest::builder() + .precompiled_module("restart-connected-client") + .build(); // Start two subscribers in the background let sub1 = test diff --git a/crates/smoketests/tests/smoketests/schedule_reducer.rs b/crates/smoketests/tests/smoketests/schedule_reducer.rs index a7ae6daee95..85a4f07a090 100644 --- a/crates/smoketests/tests/smoketests/schedule_reducer.rs +++ b/crates/smoketests/tests/smoketests/schedule_reducer.rs @@ -25,9 +25,7 @@ fn test_cancel_reducer() { /// subscription update for scheduled table entry and deletion of reducer once it ran #[test] fn test_scheduled_table_subscription() { - let test = Smoketest::builder() - .precompiled_module("schedule-subscribe") - .build(); + let test = Smoketest::builder().precompiled_module("schedule-subscribe").build(); // Call a reducer to schedule a reducer (runs immediately since timestamp is 0) test.call("schedule_reducer", &[]).unwrap(); @@ -47,9 +45,7 @@ fn test_scheduled_table_subscription() { /// Test that repeated reducers run multiple times #[test] fn test_scheduled_table_subscription_repeated_reducer() { - let test = Smoketest::builder() - .precompiled_module("schedule-subscribe") - .build(); + let test = Smoketest::builder().precompiled_module("schedule-subscribe").build(); // Call a reducer to schedule a repeated reducer test.call("schedule_repeated_reducer", &[]).unwrap(); diff --git a/tools/xtask-smoketest/src/main.rs b/tools/xtask-smoketest/src/main.rs index a4895d7fc79..95949f59c59 100644 --- a/tools/xtask-smoketest/src/main.rs +++ b/tools/xtask-smoketest/src/main.rs @@ -57,7 +57,14 @@ fn build_binaries() -> Result<()> { eprintln!("Building spacetimedb-cli and spacetimedb-standalone (release)..."); let mut cmd = Command::new("cargo"); - cmd.args(["build", "--release", "-p", "spacetimedb-cli", "-p", "spacetimedb-standalone"]); + cmd.args([ + "build", + "--release", + "-p", + "spacetimedb-cli", + "-p", + "spacetimedb-standalone", + ]); // Remove cargo/rust env vars that could cause fingerprint mismatches // when the test later runs cargo build from a different environment @@ -128,10 +135,20 @@ fn run_smoketest(args: Vec) -> Result<()> { let status = if use_nextest { eprintln!("Running smoketests with cargo nextest (release)...\n"); let mut cmd = Command::new("cargo"); - cmd.args(["nextest", "run", "--release", "-p", "spacetimedb-smoketests", "--no-fail-fast"]); + cmd.args([ + "nextest", + "run", + "--release", + "-p", + "spacetimedb-smoketests", + "--no-fail-fast", + ]); // Set default parallelism if user didn't specify -j - if !args.iter().any(|a| a == "-j" || a.starts_with("-j") || a.starts_with("--jobs")) { + if !args + .iter() + .any(|a| a == "-j" || a.starts_with("-j") || a.starts_with("--jobs")) + { cmd.args(["-j", DEFAULT_PARALLELISM]); } From c45e5671f6e7bf2934b4f5f69ef8fa68a43d687f Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 26 Jan 2026 00:02:54 -0500 Subject: [PATCH 045/118] Fix C# smoketest parallel execution by removing NuGet cache clearing - Remove `dotnet nuget locals all --clear` from csharp_module.rs and quickstart.rs which caused race conditions when tests ran in parallel - Add `` in NuGet.Config packageSources to avoid inheriting sources from machine/user config (proper isolation without clearing) - Add explicit nuget.org source URL for reliable fallback - Use `-c Release` for dotnet pack to match CI configuration - Add dotnet clean before pack to avoid stale artifacts - Add duplicate source/mapping checks in override_nuget_package - Add durability checks in restart tests before server restart --- .../tests/smoketests/csharp_module.rs | 16 ++++------ .../smoketests/tests/smoketests/quickstart.rs | 30 ++++++++++--------- crates/smoketests/tests/smoketests/restart.rs | 22 ++++++++++++++ 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/crates/smoketests/tests/smoketests/csharp_module.rs b/crates/smoketests/tests/smoketests/csharp_module.rs index 6feb824359f..7dc6c30159f 100644 --- a/crates/smoketests/tests/smoketests/csharp_module.rs +++ b/crates/smoketests/tests/smoketests/csharp_module.rs @@ -21,14 +21,6 @@ fn test_build_csharp_module() { // CLI is pre-built by artifact dependencies during compilation let cli_path = ensure_binaries_built(); - // Clear nuget locals - let status = Command::new("dotnet") - .args(["nuget", "locals", "all", "--clear"]) - .current_dir(&bindings) - .status() - .expect("Failed to clear nuget locals"); - assert!(status.success(), "Failed to clear nuget locals"); - // Install wasi-experimental workload let _status = Command::new("dotnet") .args(["workload", "install", "wasi-experimental", "--skip-manifest-update"]) @@ -37,9 +29,9 @@ fn test_build_csharp_module() { .expect("Failed to install wasi workload"); // This may fail if already installed, so we don't assert success - // Pack the bindings + // Pack the bindings in Release configuration let status = Command::new("dotnet") - .args(["pack"]) + .args(["pack", "-c", "Release"]) .current_dir(&bindings) .status() .expect("Failed to pack bindings"); @@ -70,8 +62,10 @@ fn test_build_csharp_module() { let server_path = tmpdir.path().join("spacetimedb"); // Create nuget.config with local package sources + // Use to avoid inheriting sources from machine/user config let packed_projects = ["BSATN.Runtime", "Runtime"]; - let mut sources = String::new(); + let mut sources = + String::from(" \n \n"); let mut mappings = String::new(); for project in &packed_projects { diff --git a/crates/smoketests/tests/smoketests/quickstart.rs b/crates/smoketests/tests/smoketests/quickstart.rs index 48a781bc3d2..f7c871447ce 100644 --- a/crates/smoketests/tests/smoketests/quickstart.rs +++ b/crates/smoketests/tests/smoketests/quickstart.rs @@ -78,10 +78,11 @@ fn build_typescript_sdk() -> Result<()> { Ok(()) } -/// Load NuGet config from a file, returning a simple representation. -/// We'll use a string-based approach for simplicity since we don't have xmltodict. +/// Create NuGet config with proper source isolation. +/// Uses `` to avoid inheriting sources from machine/user config. fn create_nuget_config(sources: &[(String, PathBuf)], mappings: &[(String, String)]) -> String { - let mut source_lines = String::new(); + let mut source_lines = String::from(" \n"); + source_lines.push_str(" \n"); let mut mapping_lines = String::new(); for (key, path) in sources { @@ -110,9 +111,12 @@ fn create_nuget_config(sources: &[(String, PathBuf)], mappings: &[(String, Strin /// Override nuget config to use a local NuGet package on a .NET project. fn override_nuget_package(project_dir: &Path, package: &str, source_dir: &Path, build_subdir: &str) -> Result<()> { + // Clean before packing to avoid stale artifacts causing conflicts + let _ = Command::new("dotnet").args(["clean"]).current_dir(source_dir).output(); + // Make sure the local package is built let output = Command::new("dotnet") - .args(["pack"]) + .args(["pack", "-c", "Release"]) .current_dir(source_dir) .output() .context("Failed to run dotnet pack")?; @@ -137,11 +141,15 @@ fn override_nuget_package(project_dir: &Path, package: &str, source_dir: &Path, (Vec::new(), Vec::new()) }; - // Add new source - sources.push((package.to_string(), package_path)); + // Add new source only if not already present (avoid duplicates) + if !sources.iter().any(|(k, _)| k == package) { + sources.push((package.to_string(), package_path)); + } - // Add mapping for the package - mappings.push((package.to_string(), package.to_string())); + // Add mapping for the package only if not already present + if !mappings.iter().any(|(k, _)| k == package) { + mappings.push((package.to_string(), package.to_string())); + } // Ensure nuget.org fallback exists if !mappings.iter().any(|(k, _)| k == "nuget.org") { @@ -152,12 +160,6 @@ fn override_nuget_package(project_dir: &Path, package: &str, source_dir: &Path, let config = create_nuget_config(&sources, &mappings); fs::write(&nuget_config_path, config)?; - // Clear nuget caches - let _ = Command::new("dotnet") - .args(["nuget", "locals", "--clear", "all"]) - .stderr(Stdio::null()) - .output(); - Ok(()) } diff --git a/crates/smoketests/tests/smoketests/restart.rs b/crates/smoketests/tests/smoketests/restart.rs index 2a30610fa7d..58a834166e2 100644 --- a/crates/smoketests/tests/smoketests/restart.rs +++ b/crates/smoketests/tests/smoketests/restart.rs @@ -12,6 +12,17 @@ fn test_restart_module() { test.call("add", &["Robert"]).unwrap(); + // Wait for data to be durable before restarting. + // The --confirmed flag ensures we only see durable data. + let output = test + .sql_confirmed("SELECT * FROM person WHERE name = 'Robert'") + .unwrap(); + assert!( + output.contains("Robert"), + "Data not confirmed before restart: {}", + output + ); + test.restart_server(); test.call("add", &["Julie"]).unwrap(); @@ -46,6 +57,17 @@ fn test_restart_sql() { test.call("add", &["Julie"]).unwrap(); test.call("add", &["Samantha"]).unwrap(); + // Wait for all data to be durable before restarting. + // Query the last inserted row to ensure all data is confirmed. + let output = test + .sql_confirmed("SELECT * FROM person WHERE name = 'Samantha'") + .unwrap(); + assert!( + output.contains("Samantha"), + "Data not confirmed before restart: {}", + output + ); + test.restart_server(); let output = test.sql("SELECT name FROM person WHERE id = 3").unwrap(); From fcec6eedc952eb7b498eac1bb769f3c1f60bd24b Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 26 Jan 2026 00:51:31 -0500 Subject: [PATCH 046/118] Fix clippy warning: use is_some_and instead of map_or --- crates/smoketests/src/modules.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/smoketests/src/modules.rs b/crates/smoketests/src/modules.rs index a387a5dd257..4f1233bb1b9 100644 --- a/crates/smoketests/src/modules.rs +++ b/crates/smoketests/src/modules.rs @@ -199,7 +199,7 @@ mod tests { let registry = REGISTRY.get_or_init(build_registry); for (name, path) in registry.iter() { assert!( - path.extension().map_or(false, |ext| ext == "wasm"), + path.extension().is_some_and(|ext| ext == "wasm"), "Module {} path should end with .wasm: {:?}", name, path From 9b47a1b5545a58c673e3b23ad4b1cedb3aa0b72b Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 26 Jan 2026 12:35:33 -0500 Subject: [PATCH 047/118] Fix lint --- tools/xtask-smoketest/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/xtask-smoketest/src/main.rs b/tools/xtask-smoketest/src/main.rs index 95949f59c59..4509aef1b2c 100644 --- a/tools/xtask-smoketest/src/main.rs +++ b/tools/xtask-smoketest/src/main.rs @@ -1,3 +1,4 @@ +#![allow(clippy::disallowed_macros)] use anyhow::{ensure, Result}; use clap::{Parser, Subcommand}; use std::env; From 363f9212aa08a79a4da3be04790054d90e4bcc33 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 26 Jan 2026 12:46:41 -0500 Subject: [PATCH 048/118] Add Rust smoketests to CI with nextest, keep Python smoketests running - Rename existing smoketests job to use cargo smoketest (xtask) which handles pre-building binaries, using nextest, and optimal parallelism - Add cargo-nextest installation step for better parallel execution - Add new "Smoketests (Python Legacy)" job that runs the old Python smoketests alongside the Rust ones for comparison during transition - Add warn-python-smoketests job that posts a PR comment when Python smoketests are modified, encouraging use of Rust smoketests instead - Update smoketests/README.md to note the transition to Rust smoketests --- .github/workflows/ci.yml | 162 ++++++++++++++++++++++++++++++++++++++- smoketests/README.md | 11 +++ tools/ci/src/main.rs | 7 +- 3 files changed, 178 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b62299ef7e..5a06ea0c9e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,7 @@ jobs: # `cargo build --manifest-path` (which apparently build different dependency trees). # However, we've been unable to fix it so... /shrug - name: Check v8 outputs - shell: bash + shell: bash run: | find "${CARGO_TARGET_DIR}"/ -type f | grep '[/_]v8' || true if ! [ -f "${CARGO_TARGET_DIR}"/debug/gn_out/obj/librusty_v8.a ]; then @@ -110,9 +110,91 @@ jobs: cargo build -p v8 fi + - name: Install cargo-nextest + uses: taiki-e/install-action@nextest + - name: Run smoketests run: cargo ci smoketests + smoketests-python: + needs: [lints] + name: Smoketests (Python Legacy) + strategy: + matrix: + runner: [spacetimedb-new-runner, windows-latest] + include: + - runner: spacetimedb-new-runner + container: + image: localhost:5000/spacetimedb-ci:latest + options: --privileged + - runner: windows-latest + container: null + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container }} + timeout-minutes: 120 + steps: + - name: Find Git ref + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + PR_NUMBER="${{ github.event.inputs.pr_number || null }}" + if test -n "${PR_NUMBER}"; then + GIT_REF="$( gh pr view --repo clockworklabs/SpacetimeDB $PR_NUMBER --json headRefName --jq .headRefName )" + else + GIT_REF="${{ github.ref }}" + fi + echo "GIT_REF=${GIT_REF}" >>"$GITHUB_ENV" + + - name: Checkout sources + uses: actions/checkout@v4 + with: + ref: ${{ env.GIT_REF }} + + - uses: dsherret/rust-toolchain-file@v1 + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: ${{ github.workspace }} + shared-key: spacetimedb + # Let the Rust smoketests job save the cache since it builds more things + save-if: false + prefix-key: v1 + + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r smoketests/requirements.txt + + - name: Install psql (Windows) + if: runner.os == 'Windows' + run: choco install psql -y --no-progress + shell: powershell + + - name: Update dotnet workloads + if: runner.os == 'Windows' + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + cd modules + dotnet workload config --update-mode manifests + dotnet workload update + + - name: Run Python smoketests + shell: bash + run: | + python -m smoketests + test: needs: [lints] name: Test Suite @@ -883,3 +965,81 @@ jobs: repo: targetRepo, run_id: runId, }); + + warn-python-smoketests: + name: Warn about Python smoketest edits + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Checkout sources + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for Python smoketest changes + id: check-changes + run: | + # Get changed files in the PR + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) + + # Check if any files in smoketests/ (Python) directory were changed + PYTHON_SMOKETEST_CHANGES=$(echo "$CHANGED_FILES" | grep -E "^smoketests/" || true) + + if [ -n "$PYTHON_SMOKETEST_CHANGES" ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "changed_files<> $GITHUB_OUTPUT + echo "$PYTHON_SMOKETEST_CHANGES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + else + echo "has_changes=false" >> $GITHUB_OUTPUT + fi + + - name: Add warning comment + if: steps.check-changes.outputs.has_changes == 'true' + uses: actions/github-script@v7 + with: + script: | + const changedFiles = `${{ steps.check-changes.outputs.changed_files }}`; + const body = `## ⚠️ Python Smoketests Notice + + This PR modifies files in the legacy Python smoketests directory (\`smoketests/\`). + + **Note:** The Python smoketests are being replaced by Rust smoketests in \`crates/smoketests/\`. Please consider: + + 1. If adding new tests, please add them to the Rust smoketests instead + 2. If fixing bugs, consider whether the fix is also needed in the Rust version + 3. Both test suites currently run in CI during the transition period + + **Changed files:** + \`\`\` + ${changedFiles} + \`\`\` + `; + + // Check if we already left a comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existingComment = comments.find(c => + c.user.type === 'Bot' && + c.body.includes('Python Smoketests Notice') + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + } diff --git a/smoketests/README.md b/smoketests/README.md index ec53333efad..e3dc14b7c6e 100644 --- a/smoketests/README.md +++ b/smoketests/README.md @@ -1,3 +1,14 @@ +# Python Smoketests (Legacy) + +> **Note:** These Python smoketests are being replaced by Rust smoketests in `crates/smoketests/`. +> Both test suites currently run in CI to ensure consistency during the transition. +> +> For new tests, please add them to the Rust smoketests. See `crates/smoketests/DEVELOP.md` for instructions. + +--- + +## Running the Python Smoketests + To use the smoketests, you first need to install the dependencies: ``` diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index bce50903e4b..3964fd598c2 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -390,9 +390,14 @@ fn main() -> Result<()> { } Some(CiCmd::Smoketests { args: smoketest_args }) => { + // Use cargo smoketest (alias for xtask-smoketest) which handles: + // - Building binaries first (prevents race conditions) + // - Building precompiled modules + // - Using nextest if available, falling back to cargo test + // - Running in release mode with optimal parallelism cmd( "cargo", - ["test", "-p", "spacetimedb-smoketests"] + ["smoketest"] .into_iter() .chain(smoketest_args.iter().map(|s| s.as_str()).clone()), ) From 236b3d787a00e1f219bbbe99734c334cddf8481d Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 26 Jan 2026 12:51:10 -0500 Subject: [PATCH 049/118] Simplify Python smoketest check to fail instead of commenting --- .github/workflows/ci.yml | 69 +++++++--------------------------------- 1 file changed, 11 insertions(+), 58 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a06ea0c9e0..046611d97a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -967,7 +967,7 @@ jobs: }); warn-python-smoketests: - name: Warn about Python smoketest edits + name: Check for Python smoketest edits runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: @@ -976,8 +976,7 @@ jobs: with: fetch-depth: 0 - - name: Check for Python smoketest changes - id: check-changes + - name: Fail if Python smoketests were modified run: | # Get changed files in the PR CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) @@ -986,60 +985,14 @@ jobs: PYTHON_SMOKETEST_CHANGES=$(echo "$CHANGED_FILES" | grep -E "^smoketests/" || true) if [ -n "$PYTHON_SMOKETEST_CHANGES" ]; then - echo "has_changes=true" >> $GITHUB_OUTPUT - echo "changed_files<> $GITHUB_OUTPUT - echo "$PYTHON_SMOKETEST_CHANGES" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - else - echo "has_changes=false" >> $GITHUB_OUTPUT + echo "::error::This PR modifies legacy Python smoketests. Please add new tests to the Rust smoketests in crates/smoketests/ instead." + echo "" + echo "Changed files:" + echo "$PYTHON_SMOKETEST_CHANGES" + echo "" + echo "The Python smoketests are being replaced by Rust smoketests." + echo "See crates/smoketests/DEVELOP.md for instructions on adding Rust smoketests." + exit 1 fi - - name: Add warning comment - if: steps.check-changes.outputs.has_changes == 'true' - uses: actions/github-script@v7 - with: - script: | - const changedFiles = `${{ steps.check-changes.outputs.changed_files }}`; - const body = `## ⚠️ Python Smoketests Notice - - This PR modifies files in the legacy Python smoketests directory (\`smoketests/\`). - - **Note:** The Python smoketests are being replaced by Rust smoketests in \`crates/smoketests/\`. Please consider: - - 1. If adding new tests, please add them to the Rust smoketests instead - 2. If fixing bugs, consider whether the fix is also needed in the Rust version - 3. Both test suites currently run in CI during the transition period - - **Changed files:** - \`\`\` - ${changedFiles} - \`\`\` - `; - - // Check if we already left a comment - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const existingComment = comments.find(c => - c.user.type === 'Bot' && - c.body.includes('Python Smoketests Notice') - ); - - if (existingComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existingComment.id, - body: body - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: body - }); - } + echo "No Python smoketest changes detected." From 5b82570fdc8e8760a1c08fbebc2085c675030e0b Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 26 Jan 2026 15:56:37 -0500 Subject: [PATCH 050/118] Make Python smoketests job match original smoketests job setup --- .github/workflows/ci.yml | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 046611d97a2..e2aca8a5b2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,6 +132,8 @@ jobs: runs-on: ${{ matrix.runner }} container: ${{ matrix.container }} timeout-minutes: 120 + #env: + # CARGO_TARGET_DIR: ${{ github.workspace }}/target steps: - name: Find Git ref env: @@ -145,27 +147,35 @@ jobs: GIT_REF="${{ github.ref }}" fi echo "GIT_REF=${GIT_REF}" >>"$GITHUB_ENV" - - name: Checkout sources uses: actions/checkout@v4 with: ref: ${{ env.GIT_REF }} - - uses: dsherret/rust-toolchain-file@v1 - - name: Cache Rust dependencies uses: Swatinem/rust-cache@v2 with: workspaces: ${{ github.workspace }} shared-key: spacetimedb - # Let the Rust smoketests job save the cache since it builds more things - save-if: false + cache-on-failure: false + cache-all-crates: true + cache-workspace-crates: true prefix-key: v1 - uses: actions/setup-dotnet@v4 with: global-json-file: global.json + # nodejs and pnpm are required for the typescript quickstart smoketest + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + + - uses: pnpm/action-setup@v4 + with: + run_install: true + - name: Set up Python uses: actions/setup-python@v5 with: @@ -184,16 +194,32 @@ jobs: - name: Update dotnet workloads if: runner.os == 'Windows' run: | + # Fail properly if any individual command fails $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true + cd modules + # the sdk-manifests on windows-latest are messed up, so we need to update them dotnet workload config --update-mode manifests dotnet workload update - - name: Run Python smoketests + # This step shouldn't be needed, but somehow we end up with caches that are missing librusty_v8.a. + # ChatGPT suspects that this could be due to different build invocations using the same target dir, + # and this makes sense to me because we only see it in this job where we mix `cargo build -p` with + # `cargo build --manifest-path` (which apparently build different dependency trees). + # However, we've been unable to fix it so... /shrug + - name: Check v8 outputs shell: bash run: | - python -m smoketests + find "${CARGO_TARGET_DIR}"/ -type f | grep '[/_]v8' || true + if ! [ -f "${CARGO_TARGET_DIR}"/debug/gn_out/obj/librusty_v8.a ]; then + echo "Could not find v8 output file librusty_v8.a; rebuilding manually." + cargo clean -p v8 || true + cargo build -p v8 + fi + + - name: Run Python smoketests + run: python -m smoketests test: needs: [lints] From 0f67c824e82dd46702512cd9af9859bcf4feeb8b Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 26 Jan 2026 15:59:46 -0500 Subject: [PATCH 051/118] Fix Python smoketests to use python3/python like master's cargo ci smoketests --- .github/workflows/ci.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2aca8a5b2b..59d74cdad3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,7 +219,13 @@ jobs: fi - name: Run Python smoketests - run: python -m smoketests + shell: bash + run: | + if command -v python3 &> /dev/null; then + python3 -m smoketests + else + python -m smoketests + fi test: needs: [lints] From fa54086f7d6ce65e50f6c1f7184acb1ed44f49e1 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 26 Jan 2026 21:37:52 -0500 Subject: [PATCH 052/118] Uncomment CARGO_TARGET_DIR for smoketest jobs --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59d74cdad3e..c8c34c07d1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,8 @@ jobs: runs-on: ${{ matrix.runner }} container: ${{ matrix.container }} timeout-minutes: 120 - #env: - # CARGO_TARGET_DIR: ${{ github.workspace }}/target + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/target steps: - name: Find Git ref env: @@ -132,8 +132,8 @@ jobs: runs-on: ${{ matrix.runner }} container: ${{ matrix.container }} timeout-minutes: 120 - #env: - # CARGO_TARGET_DIR: ${{ github.workspace }}/target + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/target steps: - name: Find Git ref env: From b5d01f3de9a54093854b4149d92932310957089e Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 26 Jan 2026 21:39:08 -0500 Subject: [PATCH 053/118] Match Python smoketests job to master's docker_smoketests configuration - Add smoketest_args matrix (--docker for Linux, --no-build-cli for Windows) - Add excluded tests: -x clear_database replication teams - Add Build crates step - Add Docker daemon startup and database setup for Linux - Add database startup for Windows - Add container cleanup step - Match Python version to 3.12 on Windows like master --- .github/workflows/ci.yml | 62 +++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8c34c07d1b..13e58695881 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,10 +124,12 @@ jobs: runner: [spacetimedb-new-runner, windows-latest] include: - runner: spacetimedb-new-runner + smoketest_args: --docker container: image: localhost:5000/spacetimedb-ci:latest options: --privileged - runner: windows-latest + smoketest_args: --no-build-cli container: null runs-on: ${{ matrix.runner }} container: ${{ matrix.container }} @@ -176,56 +178,52 @@ jobs: with: run_install: true - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install -r smoketests/requirements.txt - - name: Install psql (Windows) if: runner.os == 'Windows' run: choco install psql -y --no-progress shell: powershell - - name: Update dotnet workloads + - name: Build crates + run: cargo build -p spacetimedb-cli -p spacetimedb-standalone -p spacetimedb-update + + - name: Start Docker daemon + if: runner.os == 'Linux' + run: /usr/local/bin/start-docker.sh + + - name: Build and start database (Linux) + if: runner.os == 'Linux' + run: | + # Our .dockerignore omits `target`, which our CI Dockerfile needs. + rm .dockerignore + docker compose -f .github/docker-compose.yml up -d + + - name: Build and start database (Windows) if: runner.os == 'Windows' run: | # Fail properly if any individual command fails $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true + Start-Process target/debug/spacetimedb-cli.exe -ArgumentList 'start --pg-port 5432' cd modules # the sdk-manifests on windows-latest are messed up, so we need to update them dotnet workload config --update-mode manifests dotnet workload update - # This step shouldn't be needed, but somehow we end up with caches that are missing librusty_v8.a. - # ChatGPT suspects that this could be due to different build invocations using the same target dir, - # and this makes sense to me because we only see it in this job where we mix `cargo build -p` with - # `cargo build --manifest-path` (which apparently build different dependency trees). - # However, we've been unable to fix it so... /shrug - - name: Check v8 outputs - shell: bash - run: | - find "${CARGO_TARGET_DIR}"/ -type f | grep '[/_]v8' || true - if ! [ -f "${CARGO_TARGET_DIR}"/debug/gn_out/obj/librusty_v8.a ]; then - echo "Could not find v8 output file librusty_v8.a; rebuilding manually." - cargo clean -p v8 || true - cargo build -p v8 - fi + - uses: actions/setup-python@v5 + with: { python-version: "3.12" } + if: runner.os == 'Windows' + + - name: Install python deps + run: python -m pip install -r smoketests/requirements.txt - name: Run Python smoketests - shell: bash - run: | - if command -v python3 &> /dev/null; then - python3 -m smoketests - else - python -m smoketests - fi + # Note: clear_database and replication only work in private + run: python -m smoketests ${{ matrix.smoketest_args }} -x clear_database replication teams + + - name: Stop containers (Linux) + if: always() && runner.os == 'Linux' + run: docker compose -f .github/docker-compose.yml down test: needs: [lints] From bd0bb6691a9ca5c2e47209ceaadb7bb28aeefe49 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 26 Jan 2026 22:35:03 -0500 Subject: [PATCH 054/118] Remove TEST_DURATIONS.md --- crates/smoketests/TEST_DURATIONS.md | 92 ----------------------------- 1 file changed, 92 deletions(-) delete mode 100644 crates/smoketests/TEST_DURATIONS.md diff --git a/crates/smoketests/TEST_DURATIONS.md b/crates/smoketests/TEST_DURATIONS.md deleted file mode 100644 index e99050b0c16..00000000000 --- a/crates/smoketests/TEST_DURATIONS.md +++ /dev/null @@ -1,92 +0,0 @@ -# Smoketest Duration Report - -Generated: 2026-01-25 - -**Total: 121.0s** (84 tests, 16 parallel, release mode) - -| Duration | Status | Test | -|----------|--------|------| -| 77.393s | PASS | `smoketests::quickstart::test_quickstart_rust` | -| 62.893s | PASS | `smoketests::cli::publish::cli_can_publish_no_conflict_with_delete_data_flag` | -| 62.865s | PASS | `smoketests::cli::publish::cli_can_publish_no_conflict_does_not_delete_data` | -| 59.912s | PASS | `smoketests::cli::publish::cli_cannot_publish_automigration_change_with_on_conflict_without_yes_break_clients` | -| 55.821s | PASS | `smoketests::quickstart::test_quickstart_typescript` | -| 55.556s | PASS | `smoketests::cli::publish::cli_cannot_publish_breaking_change_without_flag` | -| 53.045s | PASS | `smoketests::cli::publish::cli_can_publish_with_automigration_change` | -| 52.949s | PASS | `smoketests::cli::publish::cli_cannot_publish_automigration_change_without_yes_break_clients` | -| 50.787s | FAIL | `smoketests::quickstart::test_quickstart_csharp` | -| 49.373s | PASS | `smoketests::cli::publish::cli_can_publish_breaking_change_with_on_conflict_flag` | -| 41.589s | PASS | `smoketests::cli::publish::cli_can_publish_automigration_change_with_on_conflict_and_yes_break_clients` | -| 36.317s | PASS | `smoketests::cli::publish::cli_can_publish_no_conflict_without_delete_data_flag` | -| 34.333s | PASS | `smoketests::cli::publish::cli_can_publish_breaking_change_with_delete_data_flag` | -| 32.920s | PASS | `smoketests::cli::publish::cli_can_publish_automigration_change_with_delete_data_always_without_yes_break_clients` | -| 32.901s | PASS | `smoketests::cli::publish::cli_can_publish_automigration_change_with_delete_data_always_and_yes_break_clients` | -| 31.295s | PASS | `smoketests::cli::publish::cli_can_publish_spacetimedb_on_disk` | -| 29.956s | PASS | `smoketests::namespaces::test_custom_ns_csharp` | -| 28.922s | PASS | `smoketests::namespaces::test_spacetimedb_ns_csharp` | -| 18.648s | PASS | `smoketests::csharp_module::test_build_csharp_module` | -| 17.463s | PASS | `smoketests::domains::test_subdomain_behavior` | -| 17.355s | PASS | `smoketests::auto_migration::test_add_table_auto_migration` | -| 16.533s | PASS | `smoketests::auto_migration::test_reject_schema_changes` | -| 15.261s | PASS | `smoketests::default_module_clippy::test_default_module_clippy_check` | -| 15.105s | PASS | `smoketests::call::test_call_many_errors` | -| 10.927s | PASS | `smoketests::permissions::test_cannot_delete_others_database` | -| 10.030s | PASS | `smoketests::fail_initial_publish::test_fail_initial_publish` | -| 7.896s | PASS | `smoketests::modules::test_module_update` | -| 6.659s | PASS | `smoketests::detect_wasm_bindgen::test_detect_wasm_bindgen` | -| 6.651s | PASS | `smoketests::delete_database::test_delete_database` | -| 6.603s | PASS | `smoketests::domains::test_set_to_existing_name` | -| 6.572s | PASS | `smoketests::restart::test_add_remove_index_after_restart` | -| 6.471s | PASS | `smoketests::restart::test_restart_auto_disconnect` | -| 5.411s | PASS | `smoketests::schedule_reducer::test_scheduled_table_subscription_repeated_reducer` | -| 5.134s | PASS | `smoketests::detect_wasm_bindgen::test_detect_getrandom` | -| 5.131s | PASS | `smoketests::pg_wire::test_failures` | -| 4.857s | PASS | `smoketests::views::test_fail_publish_namespace_collision` | -| 4.843s | PASS | `smoketests::timestamp_route::test_timestamp_route` | -| 4.796s | PASS | `smoketests::domains::test_set_name` | -| 4.630s | PASS | `smoketests::schedule_reducer::test_scheduled_table_subscription` | -| 4.476s | PASS | `smoketests::energy::test_energy_balance` | -| 3.910s | PASS | `smoketests::views::test_fail_publish_wrong_return_type` | -| 3.375s | PASS | `smoketests::schedule_reducer::test_cancel_reducer` | -| 3.375s | PASS | `smoketests::restart::test_restart_sql` | -| 3.135s | PASS | `smoketests::restart::test_restart_module` | -| 3.111s | PASS | `smoketests::rls::test_rls_rules` | -| 3.034s | PASS | `smoketests::schedule_reducer::test_volatile_nonatomic_schedule_immediate` | -| 3.015s | PASS | `smoketests::pg_wire::test_sql_format` | -| 2.745s | PASS | `smoketests::filtering::test_filtering` | -| 2.037s | PASS | `smoketests::sql::test_sql_format` | -| 1.778s | PASS | `smoketests::views::test_query_anonymous_view_reducer` | -| 1.654s | PASS | `smoketests::servers::test_edit_server` | -| 1.640s | PASS | `smoketests::confirmed_reads::test_confirmed_reads_receive_updates` | -| 1.392s | PASS | `smoketests::add_remove_index::test_add_then_remove_index` | -| 1.293s | PASS | `smoketests::auto_inc::test_autoinc_u64` | -| 1.288s | PASS | `smoketests::auto_inc::test_autoinc_i32` | -| 1.286s | PASS | `smoketests::auto_inc::test_autoinc_unique_u64` | -| 1.286s | PASS | `smoketests::auto_inc::test_autoinc_u32` | -| 1.270s | PASS | `smoketests::auto_inc::test_autoinc_unique_i64` | -| 1.224s | PASS | `smoketests::dml::test_subscribe` | -| 1.213s | PASS | `smoketests::auto_inc::test_autoinc_i64` | -| 1.192s | PASS | `smoketests::call::test_call_errors` | -| 1.151s | PASS | `smoketests::call::test_call_reducer_procedure` | -| 1.106s | PASS | `smoketests::call::test_call_empty_errors` | -| 1.093s | PASS | `smoketests::servers::test_servers` | -| 1.062s | PASS | `smoketests::confirmed_reads::test_sql_with_confirmed_reads_receives_result` | -| 1.031s | PASS | `smoketests::new_user_flow::test_new_user_flow` | -| 1.000s | PASS | `smoketests::views::test_st_view_tables` | -| 0.809s | PASS | `smoketests::client_connection_errors::test_client_disconnected_error_still_deletes_st_client` | -| 0.724s | PASS | `smoketests::views::test_http_sql_views` | -| 0.630s | PASS | `smoketests::permissions::test_lifecycle_reducers_cant_be_called` | -| 0.623s | PASS | `smoketests::panic::test_reducer_error_message` | -| 0.622s | PASS | `smoketests::permissions::test_private_table` | -| 0.598s | PASS | `smoketests::module_nested_op::test_module_nested_op` | -| 0.593s | PASS | `smoketests::modules::test_upload_module` | -| 0.537s | PASS | `smoketests::panic::test_panic` | -| 0.523s | PASS | `smoketests::client_connection_errors::test_client_connected_error_rejects_connection` | -| 0.514s | PASS | `smoketests::describe::test_describe` | -| 0.402s | PASS | `smoketests::connect_disconnect_from_cli::test_conn_disconn` | -| 0.215s | PASS | `smoketests::cli::server::cli_can_ping_spacetimedb_on_disk` | -| 0.102s | PASS | `smoketests::cli::dev::cli_init_with_template_creates_project` | -| 0.080s | PASS | `smoketests::create_project::test_create_project` | -| 0.074s | PASS | `smoketests::cli::dev::cli_dev_help_shows_template_option` | -| 0.067s | PASS | `smoketests::cli::dev::cli_dev_accepts_short_template_flag` | -| 0.031s | PASS | `smoketests::cli::dev::cli_dev_accepts_template_flag` | From 38deed289919e122efb84d2cea9482418cd42777 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 26 Jan 2026 22:38:23 -0500 Subject: [PATCH 055/118] Auto-discover precompiled modules instead of manual mapping Scans the target directory for smoketest_module_*.wasm files and derives module names from filenames (underscores become hyphens). This eliminates the need to manually maintain the registry. --- crates/smoketests/src/modules.rs | 200 +++++++++---------------------- 1 file changed, 55 insertions(+), 145 deletions(-) diff --git a/crates/smoketests/src/modules.rs b/crates/smoketests/src/modules.rs index 4f1233bb1b9..b4efeeed36a 100644 --- a/crates/smoketests/src/modules.rs +++ b/crates/smoketests/src/modules.rs @@ -5,6 +5,9 @@ //! //! Modules are built from the nested workspace at `crates/smoketests/modules/` //! and their WASM outputs are stored in that workspace's target directory. +//! +//! Module names are automatically derived from WASM filenames: +//! - `smoketest_module_foo_bar.wasm` → module name `foo-bar` use std::collections::HashMap; use std::path::PathBuf; @@ -13,7 +16,7 @@ use std::sync::OnceLock; use crate::workspace_root; /// Registry mapping module names to their pre-compiled WASM paths. -static REGISTRY: OnceLock> = OnceLock::new(); +static REGISTRY: OnceLock> = OnceLock::new(); /// Returns the path to a pre-compiled module's WASM file. /// @@ -36,11 +39,22 @@ pub fn precompiled_module(name: &str) -> PathBuf { /// Returns true if pre-compiled modules are available. /// /// This checks if the modules workspace target directory exists and contains -/// at least one WASM file. Tests can use this to fall back to runtime -/// compilation if precompiled modules aren't available. +/// at least one WASM file. pub fn precompiled_modules_available() -> bool { let target = modules_target_dir(); - target.exists() && target.join("smoketest_module_filtering.wasm").exists() + if !target.exists() { + return false; + } + // Check if there's at least one smoketest_module_*.wasm file + std::fs::read_dir(&target) + .map(|entries| { + entries.filter_map(Result::ok).any(|e| { + e.file_name() + .to_str() + .is_some_and(|n| n.starts_with("smoketest_module_") && n.ends_with(".wasm")) + }) + }) + .unwrap_or(false) } /// Returns the target directory where pre-compiled WASM modules are stored. @@ -48,138 +62,39 @@ fn modules_target_dir() -> PathBuf { workspace_root().join("crates/smoketests/modules/target/wasm32-unknown-unknown/release") } -/// Builds the registry mapping module names to WASM paths. -fn build_registry() -> HashMap<&'static str, PathBuf> { +/// Builds the registry by scanning the target directory for WASM files. +/// +/// Module names are derived from filenames: +/// - `smoketest_module_foo_bar.wasm` → `foo-bar` +fn build_registry() -> HashMap { let target = modules_target_dir(); - let mut reg = HashMap::new(); - // Filtering and query tests - reg.insert("filtering", target.join("smoketest_module_filtering.wasm")); - reg.insert("dml", target.join("smoketest_module_dml.wasm")); - - // Views tests - reg.insert("views-basic", target.join("smoketest_module_views_basic.wasm")); - // views-broken-namespace and views-broken-return-type are intentionally broken, not precompiled - reg.insert("views-sql", target.join("smoketest_module_views_sql.wasm")); - - // Security and permissions - reg.insert("rls", target.join("smoketest_module_rls.wasm")); - reg.insert( - "permissions-private", - target.join("smoketest_module_permissions_private.wasm"), - ); - reg.insert( - "permissions-lifecycle", - target.join("smoketest_module_permissions_lifecycle.wasm"), - ); - - // Call/procedure tests - reg.insert( - "call-reducer-procedure", - target.join("smoketest_module_call_reducer_procedure.wasm"), - ); - reg.insert("call-empty", target.join("smoketest_module_call_empty.wasm")); - - // SQL format tests - reg.insert("sql-format", target.join("smoketest_module_sql_format.wasm")); - reg.insert("pg-wire", target.join("smoketest_module_pg_wire.wasm")); - - // Scheduled reducer tests - reg.insert("schedule-cancel", target.join("smoketest_module_schedule_cancel.wasm")); - reg.insert( - "schedule-subscribe", - target.join("smoketest_module_schedule_subscribe.wasm"), - ); - reg.insert( - "schedule-volatile", - target.join("smoketest_module_schedule_volatile.wasm"), - ); + let Ok(entries) = std::fs::read_dir(&target) else { + return reg; + }; - // Module lifecycle tests - reg.insert("describe", target.join("smoketest_module_describe.wasm")); - reg.insert("modules-basic", target.join("smoketest_module_modules_basic.wasm")); - // modules-breaking is intentionally broken, not precompiled - reg.insert( - "modules-add-table", - target.join("smoketest_module_modules_add_table.wasm"), - ); + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + let Some(filename) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; - // Index tests - reg.insert( - "add-remove-index", - target.join("smoketest_module_add_remove_index.wasm"), - ); - reg.insert( - "add-remove-index-indexed", - target.join("smoketest_module_add_remove_index_indexed.wasm"), - ); - - // Panic/error handling - reg.insert("panic", target.join("smoketest_module_panic.wasm")); - reg.insert("panic-error", target.join("smoketest_module_panic_error.wasm")); - - // Restart tests - reg.insert("restart-person", target.join("smoketest_module_restart_person.wasm")); - reg.insert( - "restart-connected-client", - target.join("smoketest_module_restart_connected_client.wasm"), - ); - - // Connection tests - reg.insert( - "connect-disconnect", - target.join("smoketest_module_connect_disconnect.wasm"), - ); - reg.insert("confirmed-reads", target.join("smoketest_module_confirmed_reads.wasm")); - reg.insert("delete-database", target.join("smoketest_module_delete_database.wasm")); - reg.insert( - "client-connection-reject", - target.join("smoketest_module_client_connection_reject.wasm"), - ); - reg.insert( - "client-connection-disconnect-panic", - target.join("smoketest_module_client_connection_disconnect_panic.wasm"), - ); + // Only process smoketest_module_*.wasm files + if !filename.starts_with("smoketest_module_") || !filename.ends_with(".wasm") { + continue; + } - // Misc tests - reg.insert("namespaces", target.join("smoketest_module_namespaces.wasm")); - reg.insert("new-user-flow", target.join("smoketest_module_new_user_flow.wasm")); - reg.insert( - "module-nested-op", - target.join("smoketest_module_module_nested_op.wasm"), - ); - // fail-initial-publish-broken is intentionally broken, not precompiled - reg.insert( - "fail-initial-publish-fixed", - target.join("smoketest_module_fail_initial_publish_fixed.wasm"), - ); + // Extract module name: smoketest_module_foo_bar.wasm -> foo-bar + let module_name = filename + .strip_prefix("smoketest_module_") + .unwrap() + .strip_suffix(".wasm") + .unwrap() + .replace('_', "-"); - // Auto-increment tests (parameterized variants) - reg.insert( - "autoinc-basic-u32", - target.join("smoketest_module_autoinc_basic_u32.wasm"), - ); - reg.insert( - "autoinc-basic-u64", - target.join("smoketest_module_autoinc_basic_u64.wasm"), - ); - reg.insert( - "autoinc-basic-i32", - target.join("smoketest_module_autoinc_basic_i32.wasm"), - ); - reg.insert( - "autoinc-basic-i64", - target.join("smoketest_module_autoinc_basic_i64.wasm"), - ); - reg.insert( - "autoinc-unique-u64", - target.join("smoketest_module_autoinc_unique_u64.wasm"), - ); - reg.insert( - "autoinc-unique-i64", - target.join("smoketest_module_autoinc_unique_i64.wasm"), - ); + reg.insert(module_name, path); + } reg } @@ -189,21 +104,16 @@ mod tests { use super::*; #[test] - fn test_registry_has_entries() { - let registry = REGISTRY.get_or_init(build_registry); - assert!(!registry.is_empty(), "Registry should have entries"); - } - - #[test] - fn test_module_paths_end_with_wasm() { - let registry = REGISTRY.get_or_init(build_registry); - for (name, path) in registry.iter() { - assert!( - path.extension().is_some_and(|ext| ext == "wasm"), - "Module {} path should end with .wasm: {:?}", - name, - path - ); - } + fn test_module_name_derivation() { + // Test the naming convention + let filename = "smoketest_module_foo_bar.wasm"; + let expected = "foo-bar"; + let actual = filename + .strip_prefix("smoketest_module_") + .unwrap() + .strip_suffix(".wasm") + .unwrap() + .replace('_', "-"); + assert_eq!(actual, expected); } } From ed8660067e803db4a9d51502ccd0bfe591d92eec Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 26 Jan 2026 22:39:11 -0500 Subject: [PATCH 056/118] Apply suggestions from code review Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com> Signed-off-by: Tyler Cloutier --- .github/workflows/ci.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13e58695881..350ff3f40d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1008,11 +1008,7 @@ jobs: - name: Fail if Python smoketests were modified run: | - # Get changed files in the PR - CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) - - # Check if any files in smoketests/ (Python) directory were changed - PYTHON_SMOKETEST_CHANGES=$(echo "$CHANGED_FILES" | grep -E "^smoketests/" || true) + PYTHON_SMOKETEST_CHANGES=$(git diff --name-only origin/${{ github.base_ref }} HEAD -- 'smoketests/**.py') if [ -n "$PYTHON_SMOKETEST_CHANGES" ]; then echo "::error::This PR modifies legacy Python smoketests. Please add new tests to the Rust smoketests in crates/smoketests/ instead." From 7414be7b711e50edce87824af268480ee71ce71e Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 26 Jan 2026 22:40:09 -0500 Subject: [PATCH 057/118] Add permissions block to warn-python-smoketests job --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 350ff3f40d2..91eababefda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1000,6 +1000,8 @@ jobs: name: Check for Python smoketest edits runs-on: ubuntu-latest if: github.event_name == 'pull_request' + permissions: + contents: read steps: - name: Checkout sources uses: actions/checkout@v4 From 42f679e6125acbbed560390699e924347d4047c2 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Mon, 26 Jan 2026 23:27:18 -0500 Subject: [PATCH 058/118] Add remote server support to Rust smoketests Enable running Rust smoketests against a remote server instead of spawning local servers, similar to Python smoketests --remote-server. - Add SPACETIME_REMOTE_SERVER env var support to skip local server spawn - Add --server CLI option to cargo smoketest - Add skip_if_remote!() macro for tests requiring local server control - Mark restart tests with skip_if_remote!() since they need local server --- crates/smoketests/src/lib.rs | 89 ++++++++++++++++--- crates/smoketests/src/modules.rs | 2 - crates/smoketests/tests/smoketests/restart.rs | 6 +- tools/xtask-smoketest/src/main.rs | 46 ++++++++-- 4 files changed, 121 insertions(+), 22 deletions(-) diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 065648e074c..f7e3854382f 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -62,6 +62,48 @@ use std::process::{Command, Output, Stdio}; use std::sync::OnceLock; use std::time::Instant; +/// Returns the remote server URL if running against a remote server. +/// +/// Set the `SPACETIME_REMOTE_SERVER` environment variable to run tests against +/// a remote server instead of spawning local servers. +pub fn remote_server_url() -> Option { + std::env::var("SPACETIME_REMOTE_SERVER").ok() +} + +/// Returns true if running against a remote server. +pub fn is_remote_server() -> bool { + remote_server_url().is_some() +} + +/// Skip this test if running against a remote server. +/// +/// Use this macro at the start of tests that require a local server, +/// such as tests that call `restart_server()` or access local data directories. +/// +/// # Example +/// +/// ```ignore +/// #[test] +/// fn test_restart() { +/// skip_if_remote!(); +/// let mut test = Smoketest::builder().build(); +/// test.restart_server(); +/// // ... +/// } +/// ``` +#[macro_export] +macro_rules! skip_if_remote { + () => { + if $crate::is_remote_server() { + #[allow(clippy::disallowed_macros)] + { + eprintln!("Skipping test: requires local server"); + } + return; + } + }; +} + /// Helper macro for timing operations and printing results macro_rules! timed { ($label:expr, $expr:expr) => {{ @@ -222,7 +264,8 @@ pub fn parse_quickstart(doc_content: &str, language: &str, module_name: &str, se /// A smoketest instance that manages a SpacetimeDB server and module project. pub struct Smoketest { /// The SpacetimeDB server guard (stops server on drop). - pub guard: SpacetimeDbGuard, + /// None when running against a remote server. + pub guard: Option, /// Temporary directory containing the module project. pub project_dir: tempfile::TempDir, /// Database identity after publishing (if any). @@ -347,8 +390,13 @@ impl SmoketestBuilder { /// Builds the `Smoketest` instance. /// - /// This spawns a SpacetimeDB server, creates a temporary project directory, - /// writes the module code, and optionally publishes the module. + /// This spawns a SpacetimeDB server (unless `SPACETIME_REMOTE_SERVER` is set), + /// creates a temporary project directory, writes the module code, and optionally + /// publishes the module. + /// + /// When `SPACETIME_REMOTE_SERVER` is set, tests run against the remote server + /// instead of spawning a local server. Tests that require local server control + /// (like restart tests) should use `skip_if_remote!()` at the start. /// /// # Panics /// @@ -359,10 +407,19 @@ impl SmoketestBuilder { let _ = ensure_binaries_built(); let build_start = Instant::now(); - let guard = timed!( - "server spawn", - SpacetimeDbGuard::spawn_in_temp_data_dir_with_pg_port(self.pg_port) - ); + // Check if we're running against a remote server + let (guard, server_url) = if let Some(remote_url) = remote_server_url() { + eprintln!("[REMOTE] Using remote server: {}", remote_url); + (None, remote_url) + } else { + let guard = timed!( + "server spawn", + SpacetimeDbGuard::spawn_in_temp_data_dir_with_pg_port(self.pg_port) + ); + let url = guard.host_url.clone(); + (Some(guard), url) + }; + let project_dir = tempfile::tempdir().expect("Failed to create temp project directory"); // Check if we're using a pre-compiled module @@ -435,7 +492,6 @@ pub fn noop(_ctx: &ReducerContext) {} eprintln!("[TIMING] project setup: {:?}", project_setup_start.elapsed()); } - let server_url = guard.host_url.clone(); let config_path = project_dir.path().join("config.toml"); let mut smoketest = Smoketest { guard, @@ -467,10 +523,18 @@ impl Smoketest { /// This stops the current server process and starts a new one with the /// same data directory. All data is preserved across the restart. /// The server URL may change since a new ephemeral port is allocated. + /// + /// # Panics + /// + /// Panics if running against a remote server (no local server to restart). + /// Tests that call this method should use `skip_if_remote!()` at the start. pub fn restart_server(&mut self) { - self.guard.restart(); + let guard = self.guard.as_mut().expect( + "Cannot restart server: running against remote server. Use skip_if_remote!() at the start of this test.", + ); + guard.restart(); // Update server_url since the port may have changed - self.server_url = self.guard.host_url.clone(); + self.server_url = guard.host_url.clone(); } /// Returns the server host (without protocol), e.g., "127.0.0.1:3000". @@ -482,8 +546,11 @@ impl Smoketest { } /// Returns the PostgreSQL wire protocol port, if enabled. + /// + /// Returns None if running against a remote server or if PostgreSQL + /// wire protocol wasn't enabled for the local server. pub fn pg_port(&self) -> Option { - self.guard.pg_port + self.guard.as_ref().and_then(|g| g.pg_port) } /// Reads the authentication token from the config file. diff --git a/crates/smoketests/src/modules.rs b/crates/smoketests/src/modules.rs index b4efeeed36a..9b87f271c38 100644 --- a/crates/smoketests/src/modules.rs +++ b/crates/smoketests/src/modules.rs @@ -101,8 +101,6 @@ fn build_registry() -> HashMap { #[cfg(test)] mod tests { - use super::*; - #[test] fn test_module_name_derivation() { // Test the naming convention diff --git a/crates/smoketests/tests/smoketests/restart.rs b/crates/smoketests/tests/smoketests/restart.rs index 58a834166e2..2da90a7d052 100644 --- a/crates/smoketests/tests/smoketests/restart.rs +++ b/crates/smoketests/tests/smoketests/restart.rs @@ -1,13 +1,14 @@ //! Tests for server restart behavior. //! Translated from smoketests/tests/zz_docker.py -use spacetimedb_smoketests::Smoketest; +use spacetimedb_smoketests::{skip_if_remote, Smoketest}; /// Test data persistence across server restart. /// /// This tests to see if SpacetimeDB can be queried after a restart. #[test] fn test_restart_module() { + skip_if_remote!(); let mut test = Smoketest::builder().precompiled_module("restart-person").build(); test.call("add", &["Robert"]).unwrap(); @@ -51,6 +52,7 @@ fn test_restart_module() { /// Test SQL queries work after restart. #[test] fn test_restart_sql() { + skip_if_remote!(); let mut test = Smoketest::builder().precompiled_module("restart-person").build(); test.call("add", &["Robert"]).unwrap(); @@ -81,6 +83,7 @@ fn test_restart_sql() { /// Test clients are auto-disconnected on restart. #[test] fn test_restart_auto_disconnect() { + skip_if_remote!(); let mut test = Smoketest::builder() .precompiled_module("restart-connected-client") .build(); @@ -132,6 +135,7 @@ const JOIN_QUERY: &str = "select t1.* from t1 join t2 on t1.id = t2.id where t2. /// to re-use IDs. #[test] fn test_add_remove_index_after_restart() { + skip_if_remote!(); let mut test = Smoketest::builder() .precompiled_module("add-remove-index") .autopublish(false) diff --git a/tools/xtask-smoketest/src/main.rs b/tools/xtask-smoketest/src/main.rs index 4509aef1b2c..86e8030b2f6 100644 --- a/tools/xtask-smoketest/src/main.rs +++ b/tools/xtask-smoketest/src/main.rs @@ -24,6 +24,14 @@ enum XtaskCmd { #[command(subcommand)] cmd: Option, + /// Run tests against a remote server instead of spawning local servers. + /// + /// When specified, tests will connect to the given URL instead of starting + /// local server instances. Tests that require local server control (like + /// restart tests) will be skipped. + #[arg(long)] + server: Option, + /// Additional arguments to pass to the test runner #[arg(trailing_var_arg = true)] args: Vec, @@ -50,7 +58,11 @@ fn main() -> Result<()> { eprintln!("Binaries ready. You can now run `cargo test --all`."); Ok(()) } - XtaskCmd::Smoketest { cmd: None, args } => run_smoketest(args), + XtaskCmd::Smoketest { + cmd: None, + server, + args, + } => run_smoketest(server, args), } } @@ -116,7 +128,7 @@ fn build_precompiled_modules() -> Result<()> { /// 16 was found to be optimal - higher values cause OS scheduler overhead. const DEFAULT_PARALLELISM: &str = "16"; -fn run_smoketest(args: Vec) -> Result<()> { +fn run_smoketest(server: Option, args: Vec) -> Result<()> { // 1. Build binaries first (single process, no race) build_binaries()?; @@ -134,7 +146,11 @@ fn run_smoketest(args: Vec) -> Result<()> { // 5. Run tests with appropriate runner (release mode for faster execution) let status = if use_nextest { - eprintln!("Running smoketests with cargo nextest (release)...\n"); + if server.is_some() { + eprintln!("Running smoketests against remote server with cargo nextest (release)...\n"); + } else { + eprintln!("Running smoketests with cargo nextest (release)...\n"); + } let mut cmd = Command::new("cargo"); cmd.args([ "nextest", @@ -153,13 +169,27 @@ fn run_smoketest(args: Vec) -> Result<()> { cmd.args(["-j", DEFAULT_PARALLELISM]); } + // Set remote server environment variable if specified + if let Some(ref server_url) = server { + cmd.env("SPACETIME_REMOTE_SERVER", server_url); + } + cmd.args(&args).status()? } else { - eprintln!("Running smoketests with cargo test (release)...\n"); - Command::new("cargo") - .args(["test", "--release", "-p", "spacetimedb-smoketests"]) - .args(&args) - .status()? + if server.is_some() { + eprintln!("Running smoketests against remote server with cargo test (release)...\n"); + } else { + eprintln!("Running smoketests with cargo test (release)...\n"); + } + let mut cmd = Command::new("cargo"); + cmd.args(["test", "--release", "-p", "spacetimedb-smoketests"]); + + // Set remote server environment variable if specified + if let Some(ref server_url) = server { + cmd.env("SPACETIME_REMOTE_SERVER", server_url); + } + + cmd.args(&args).status()? }; ensure!(status.success(), "Tests failed"); From 73d2209139ff2249b215246ed3bea00922418c9f Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 09:17:45 -0500 Subject: [PATCH 059/118] Exclude smoketests from cargo ci test Smoketests require pre-built binaries and have their own dedicated command (cargo ci smoketests) that builds binaries first. Exclude them from cargo test --all to prevent failures in CI. --- tools/ci/src/main.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index 3964fd598c2..b78e7ef0f10 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -228,8 +228,9 @@ fn main() -> Result<()> { Some(CiCmd::Test) => { // TODO: This doesn't work on at least user Linux machines, because something here apparently uses `sudo`? - // cmd!("cargo", "test", "--all", "--", "--skip", "unreal").run()?; - cmd!("cargo", "test", "--all", "--", "--test-threads=2", "--skip", "unreal").run()?; + // Exclude smoketests from `cargo test --all` since they require pre-built binaries. + // Smoketests have their own dedicated command: `cargo ci smoketests` + cmd!("cargo", "test", "--all", "--exclude", "spacetimedb-smoketests", "--", "--test-threads=2", "--skip", "unreal").run()?; // TODO: This should check for a diff at the start. If there is one, we should alert the user // that we're disabling diff checks because they have a dirty git repo, and to re-run in a clean one // if they want those checks. From 8fe5ac5d28d4585e9df7c67dd7f541d59a08dc98 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 09:24:10 -0500 Subject: [PATCH 060/118] cargo fmt --- tools/ci/src/main.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index b78e7ef0f10..6095ee2db6d 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -230,7 +230,18 @@ fn main() -> Result<()> { // Exclude smoketests from `cargo test --all` since they require pre-built binaries. // Smoketests have their own dedicated command: `cargo ci smoketests` - cmd!("cargo", "test", "--all", "--exclude", "spacetimedb-smoketests", "--", "--test-threads=2", "--skip", "unreal").run()?; + cmd!( + "cargo", + "test", + "--all", + "--exclude", + "spacetimedb-smoketests", + "--", + "--test-threads=2", + "--skip", + "unreal" + ) + .run()?; // TODO: This should check for a diff at the start. If there is one, we should alert the user // that we're disabling diff checks because they have a dirty git repo, and to re-run in a clean one // if they want those checks. From c5499c438d238137eb7566c8128b191463661dba Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 09:25:21 -0500 Subject: [PATCH 061/118] Add .NET build artifacts to .gitignore Ignore obj/ and bin/ directories which are created when dotnet commands scan for .csproj files during CI. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 6f541fda31f..d8bb147a73e 100644 --- a/.gitignore +++ b/.gitignore @@ -211,6 +211,10 @@ crates/bench/spacetime.svg crates/bench/sqlite.svg .vs/ +# .NET build artifacts +**/obj/ +**/bin/ + # benchmark files out.json old.json From 5266d58385465d55117889304ce03a4be2c64ca8 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 11:03:30 -0500 Subject: [PATCH 062/118] Fix precompiled modules not found in CI Update module discovery to respect CARGO_TARGET_DIR env var when looking for precompiled WASM modules. CI sets this for the main workspace, so the modules get built there instead of in the modules workspace's own target directory. --- crates/smoketests/src/modules.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/smoketests/src/modules.rs b/crates/smoketests/src/modules.rs index 9b87f271c38..761893ad1be 100644 --- a/crates/smoketests/src/modules.rs +++ b/crates/smoketests/src/modules.rs @@ -59,7 +59,11 @@ pub fn precompiled_modules_available() -> bool { /// Returns the target directory where pre-compiled WASM modules are stored. fn modules_target_dir() -> PathBuf { - workspace_root().join("crates/smoketests/modules/target/wasm32-unknown-unknown/release") + // Respect CARGO_TARGET_DIR if set (e.g., in CI), otherwise use the modules workspace's target dir + let base = std::env::var("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| workspace_root().join("crates/smoketests/modules/target")); + base.join("wasm32-unknown-unknown/release") } /// Builds the registry by scanning the target directory for WASM files. From 833dc984b45658ed3db220a97d5b3765428b9fd6 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 11:30:40 -0500 Subject: [PATCH 063/118] Consolidate autoinc tests to match Python semantics Replace individual autoinc modules (u32, u64, i32, i64) with two consolidated modules that test all 10 integer types (u8, u16, u32, u64, u128, i8, i16, i32, i64, i128), matching the Python tests. - Create autoinc-basic module with macro for all types - Create autoinc-unique module with macro for all types - Update test to iterate over all types in a single test - Remove individual type-specific modules --- crates/smoketests/modules/Cargo.lock | 44 +-- crates/smoketests/modules/Cargo.toml | 10 +- .../modules/autoinc-basic-i32/Cargo.toml | 12 - .../modules/autoinc-basic-i32/src/lib.rs | 23 -- .../modules/autoinc-basic-i64/Cargo.toml | 12 - .../modules/autoinc-basic-i64/src/lib.rs | 23 -- .../modules/autoinc-basic-u32/Cargo.toml | 12 - .../modules/autoinc-basic-u32/src/lib.rs | 23 -- .../modules/autoinc-basic-u64/Cargo.toml | 12 - .../modules/autoinc-basic-u64/src/lib.rs | 23 -- .../modules/autoinc-basic/Cargo.toml | 12 + .../modules/autoinc-basic/src/lib.rs | 33 +++ .../modules/autoinc-unique-i64/Cargo.toml | 12 - .../modules/autoinc-unique-i64/src/lib.rs | 33 --- .../modules/autoinc-unique-u64/Cargo.toml | 12 - .../modules/autoinc-unique-u64/src/lib.rs | 33 --- .../modules/autoinc-unique/Cargo.toml | 12 + .../modules/autoinc-unique/src/lib.rs | 43 +++ .../smoketests/tests/smoketests/auto_inc.rs | 266 +++++------------- 19 files changed, 181 insertions(+), 469 deletions(-) delete mode 100644 crates/smoketests/modules/autoinc-basic-i32/Cargo.toml delete mode 100644 crates/smoketests/modules/autoinc-basic-i32/src/lib.rs delete mode 100644 crates/smoketests/modules/autoinc-basic-i64/Cargo.toml delete mode 100644 crates/smoketests/modules/autoinc-basic-i64/src/lib.rs delete mode 100644 crates/smoketests/modules/autoinc-basic-u32/Cargo.toml delete mode 100644 crates/smoketests/modules/autoinc-basic-u32/src/lib.rs delete mode 100644 crates/smoketests/modules/autoinc-basic-u64/Cargo.toml delete mode 100644 crates/smoketests/modules/autoinc-basic-u64/src/lib.rs create mode 100644 crates/smoketests/modules/autoinc-basic/Cargo.toml create mode 100644 crates/smoketests/modules/autoinc-basic/src/lib.rs delete mode 100644 crates/smoketests/modules/autoinc-unique-i64/Cargo.toml delete mode 100644 crates/smoketests/modules/autoinc-unique-i64/src/lib.rs delete mode 100644 crates/smoketests/modules/autoinc-unique-u64/Cargo.toml delete mode 100644 crates/smoketests/modules/autoinc-unique-u64/src/lib.rs create mode 100644 crates/smoketests/modules/autoinc-unique/Cargo.toml create mode 100644 crates/smoketests/modules/autoinc-unique/src/lib.rs diff --git a/crates/smoketests/modules/Cargo.lock b/crates/smoketests/modules/Cargo.lock index 5c226fd7542..45dd72e3c46 100644 --- a/crates/smoketests/modules/Cargo.lock +++ b/crates/smoketests/modules/Cargo.lock @@ -322,6 +322,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -522,50 +528,20 @@ dependencies = [ ] [[package]] -name = "smoketest-module-autoinc-basic-i32" -version = "0.1.0" -dependencies = [ - "log", - "spacetimedb", -] - -[[package]] -name = "smoketest-module-autoinc-basic-i64" -version = "0.1.0" -dependencies = [ - "log", - "spacetimedb", -] - -[[package]] -name = "smoketest-module-autoinc-basic-u32" -version = "0.1.0" -dependencies = [ - "log", - "spacetimedb", -] - -[[package]] -name = "smoketest-module-autoinc-basic-u64" -version = "0.1.0" -dependencies = [ - "log", - "spacetimedb", -] - -[[package]] -name = "smoketest-module-autoinc-unique-i64" +name = "smoketest-module-autoinc-basic" version = "0.1.0" dependencies = [ "log", + "paste", "spacetimedb", ] [[package]] -name = "smoketest-module-autoinc-unique-u64" +name = "smoketest-module-autoinc-unique" version = "0.1.0" dependencies = [ "log", + "paste", "spacetimedb", ] diff --git a/crates/smoketests/modules/Cargo.toml b/crates/smoketests/modules/Cargo.toml index d990a309f66..24e38a98191 100644 --- a/crates/smoketests/modules/Cargo.toml +++ b/crates/smoketests/modules/Cargo.toml @@ -68,13 +68,9 @@ members = [ # "fail-initial-publish-broken" - intentionally broken, uses runtime compilation "fail-initial-publish-fixed", - # Auto-increment tests (parameterized variants) - "autoinc-basic-u32", - "autoinc-basic-u64", - "autoinc-basic-i32", - "autoinc-basic-i64", - "autoinc-unique-u64", - "autoinc-unique-i64", + # Auto-increment tests (all 10 integer types in one module each) + "autoinc-basic", + "autoinc-unique", ] [workspace.dependencies] diff --git a/crates/smoketests/modules/autoinc-basic-i32/Cargo.toml b/crates/smoketests/modules/autoinc-basic-i32/Cargo.toml deleted file mode 100644 index 596c9b17eee..00000000000 --- a/crates/smoketests/modules/autoinc-basic-i32/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "smoketest-module-autoinc-basic-i32" -version = "0.1.0" -edition = "2021" -publish = false - -[lib] -crate-type = ["cdylib"] - -[dependencies] -spacetimedb.workspace = true -log.workspace = true diff --git a/crates/smoketests/modules/autoinc-basic-i32/src/lib.rs b/crates/smoketests/modules/autoinc-basic-i32/src/lib.rs deleted file mode 100644 index f7687c1da93..00000000000 --- a/crates/smoketests/modules/autoinc-basic-i32/src/lib.rs +++ /dev/null @@ -1,23 +0,0 @@ -#![allow(non_camel_case_types)] -use spacetimedb::{log, ReducerContext, Table}; - -#[spacetimedb::table(name = person_i32)] -pub struct Person_i32 { - #[auto_inc] - key_col: i32, - name: String, -} - -#[spacetimedb::reducer] -pub fn add_i32(ctx: &ReducerContext, name: String, expected_value: i32) { - let value = ctx.db.person_i32().insert(Person_i32 { key_col: 0, name }); - assert_eq!(value.key_col, expected_value); -} - -#[spacetimedb::reducer] -pub fn say_hello_i32(ctx: &ReducerContext) { - for person in ctx.db.person_i32().iter() { - log::info!("Hello, {}:{}!", person.key_col, person.name); - } - log::info!("Hello, World!"); -} diff --git a/crates/smoketests/modules/autoinc-basic-i64/Cargo.toml b/crates/smoketests/modules/autoinc-basic-i64/Cargo.toml deleted file mode 100644 index f6273b88664..00000000000 --- a/crates/smoketests/modules/autoinc-basic-i64/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "smoketest-module-autoinc-basic-i64" -version = "0.1.0" -edition = "2021" -publish = false - -[lib] -crate-type = ["cdylib"] - -[dependencies] -spacetimedb.workspace = true -log.workspace = true diff --git a/crates/smoketests/modules/autoinc-basic-i64/src/lib.rs b/crates/smoketests/modules/autoinc-basic-i64/src/lib.rs deleted file mode 100644 index b7fe53085ad..00000000000 --- a/crates/smoketests/modules/autoinc-basic-i64/src/lib.rs +++ /dev/null @@ -1,23 +0,0 @@ -#![allow(non_camel_case_types)] -use spacetimedb::{log, ReducerContext, Table}; - -#[spacetimedb::table(name = person_i64)] -pub struct Person_i64 { - #[auto_inc] - key_col: i64, - name: String, -} - -#[spacetimedb::reducer] -pub fn add_i64(ctx: &ReducerContext, name: String, expected_value: i64) { - let value = ctx.db.person_i64().insert(Person_i64 { key_col: 0, name }); - assert_eq!(value.key_col, expected_value); -} - -#[spacetimedb::reducer] -pub fn say_hello_i64(ctx: &ReducerContext) { - for person in ctx.db.person_i64().iter() { - log::info!("Hello, {}:{}!", person.key_col, person.name); - } - log::info!("Hello, World!"); -} diff --git a/crates/smoketests/modules/autoinc-basic-u32/Cargo.toml b/crates/smoketests/modules/autoinc-basic-u32/Cargo.toml deleted file mode 100644 index 9846989be3e..00000000000 --- a/crates/smoketests/modules/autoinc-basic-u32/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "smoketest-module-autoinc-basic-u32" -version = "0.1.0" -edition = "2021" -publish = false - -[lib] -crate-type = ["cdylib"] - -[dependencies] -spacetimedb.workspace = true -log.workspace = true diff --git a/crates/smoketests/modules/autoinc-basic-u32/src/lib.rs b/crates/smoketests/modules/autoinc-basic-u32/src/lib.rs deleted file mode 100644 index d3968cc45d6..00000000000 --- a/crates/smoketests/modules/autoinc-basic-u32/src/lib.rs +++ /dev/null @@ -1,23 +0,0 @@ -#![allow(non_camel_case_types)] -use spacetimedb::{log, ReducerContext, Table}; - -#[spacetimedb::table(name = person_u32)] -pub struct Person_u32 { - #[auto_inc] - key_col: u32, - name: String, -} - -#[spacetimedb::reducer] -pub fn add_u32(ctx: &ReducerContext, name: String, expected_value: u32) { - let value = ctx.db.person_u32().insert(Person_u32 { key_col: 0, name }); - assert_eq!(value.key_col, expected_value); -} - -#[spacetimedb::reducer] -pub fn say_hello_u32(ctx: &ReducerContext) { - for person in ctx.db.person_u32().iter() { - log::info!("Hello, {}:{}!", person.key_col, person.name); - } - log::info!("Hello, World!"); -} diff --git a/crates/smoketests/modules/autoinc-basic-u64/Cargo.toml b/crates/smoketests/modules/autoinc-basic-u64/Cargo.toml deleted file mode 100644 index 4918b62ec90..00000000000 --- a/crates/smoketests/modules/autoinc-basic-u64/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "smoketest-module-autoinc-basic-u64" -version = "0.1.0" -edition = "2021" -publish = false - -[lib] -crate-type = ["cdylib"] - -[dependencies] -spacetimedb.workspace = true -log.workspace = true diff --git a/crates/smoketests/modules/autoinc-basic-u64/src/lib.rs b/crates/smoketests/modules/autoinc-basic-u64/src/lib.rs deleted file mode 100644 index 09cbd9f74a2..00000000000 --- a/crates/smoketests/modules/autoinc-basic-u64/src/lib.rs +++ /dev/null @@ -1,23 +0,0 @@ -#![allow(non_camel_case_types)] -use spacetimedb::{log, ReducerContext, Table}; - -#[spacetimedb::table(name = person_u64)] -pub struct Person_u64 { - #[auto_inc] - key_col: u64, - name: String, -} - -#[spacetimedb::reducer] -pub fn add_u64(ctx: &ReducerContext, name: String, expected_value: u64) { - let value = ctx.db.person_u64().insert(Person_u64 { key_col: 0, name }); - assert_eq!(value.key_col, expected_value); -} - -#[spacetimedb::reducer] -pub fn say_hello_u64(ctx: &ReducerContext) { - for person in ctx.db.person_u64().iter() { - log::info!("Hello, {}:{}!", person.key_col, person.name); - } - log::info!("Hello, World!"); -} diff --git a/crates/smoketests/modules/autoinc-basic/Cargo.toml b/crates/smoketests/modules/autoinc-basic/Cargo.toml new file mode 100644 index 00000000000..dd4efa36dd3 --- /dev/null +++ b/crates/smoketests/modules/autoinc-basic/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-autoinc-basic" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb = { path = "../../../../crates/bindings" } +log = "0.4" +paste = "1.0" diff --git a/crates/smoketests/modules/autoinc-basic/src/lib.rs b/crates/smoketests/modules/autoinc-basic/src/lib.rs new file mode 100644 index 00000000000..53542d417cb --- /dev/null +++ b/crates/smoketests/modules/autoinc-basic/src/lib.rs @@ -0,0 +1,33 @@ +#![allow(non_camel_case_types)] +use spacetimedb::{log, ReducerContext, Table}; + +macro_rules! autoinc_basic { + ($($ty:ident),*) => { + $( + paste::paste! { + #[spacetimedb::table(name = [])] + pub struct [] { + #[auto_inc] + key_col: $ty, + name: String, + } + + #[spacetimedb::reducer] + pub fn [](ctx: &ReducerContext, name: String, expected_value: $ty) { + let value = ctx.db.[]().insert([] { key_col: 0, name }); + assert_eq!(value.key_col, expected_value); + } + + #[spacetimedb::reducer] + pub fn [](ctx: &ReducerContext) { + for person in ctx.db.[]().iter() { + log::info!("Hello, {}:{}!", person.key_col, person.name); + } + log::info!("Hello, World!"); + } + } + )* + }; +} + +autoinc_basic!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128); diff --git a/crates/smoketests/modules/autoinc-unique-i64/Cargo.toml b/crates/smoketests/modules/autoinc-unique-i64/Cargo.toml deleted file mode 100644 index 621a1e11379..00000000000 --- a/crates/smoketests/modules/autoinc-unique-i64/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "smoketest-module-autoinc-unique-i64" -version = "0.1.0" -edition = "2021" -publish = false - -[lib] -crate-type = ["cdylib"] - -[dependencies] -spacetimedb.workspace = true -log.workspace = true diff --git a/crates/smoketests/modules/autoinc-unique-i64/src/lib.rs b/crates/smoketests/modules/autoinc-unique-i64/src/lib.rs deleted file mode 100644 index 8f653618fcc..00000000000 --- a/crates/smoketests/modules/autoinc-unique-i64/src/lib.rs +++ /dev/null @@ -1,33 +0,0 @@ -#![allow(non_camel_case_types)] -use std::error::Error; -use spacetimedb::{log, ReducerContext, Table}; - -#[spacetimedb::table(name = person_i64)] -pub struct Person_i64 { - #[auto_inc] - #[unique] - key_col: i64, - #[unique] - name: String, -} - -#[spacetimedb::reducer] -pub fn add_new_i64(ctx: &ReducerContext, name: String) -> Result<(), Box> { - let value = ctx.db.person_i64().try_insert(Person_i64 { key_col: 0, name })?; - log::info!("Assigned Value: {} -> {}", value.key_col, value.name); - Ok(()) -} - -#[spacetimedb::reducer] -pub fn update_i64(ctx: &ReducerContext, name: String, new_id: i64) { - ctx.db.person_i64().name().delete(&name); - let _value = ctx.db.person_i64().insert(Person_i64 { key_col: new_id, name }); -} - -#[spacetimedb::reducer] -pub fn say_hello_i64(ctx: &ReducerContext) { - for person in ctx.db.person_i64().iter() { - log::info!("Hello, {}:{}!", person.key_col, person.name); - } - log::info!("Hello, World!"); -} diff --git a/crates/smoketests/modules/autoinc-unique-u64/Cargo.toml b/crates/smoketests/modules/autoinc-unique-u64/Cargo.toml deleted file mode 100644 index c070a4dcd4f..00000000000 --- a/crates/smoketests/modules/autoinc-unique-u64/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "smoketest-module-autoinc-unique-u64" -version = "0.1.0" -edition = "2021" -publish = false - -[lib] -crate-type = ["cdylib"] - -[dependencies] -spacetimedb.workspace = true -log.workspace = true diff --git a/crates/smoketests/modules/autoinc-unique-u64/src/lib.rs b/crates/smoketests/modules/autoinc-unique-u64/src/lib.rs deleted file mode 100644 index 9d39a1a2247..00000000000 --- a/crates/smoketests/modules/autoinc-unique-u64/src/lib.rs +++ /dev/null @@ -1,33 +0,0 @@ -#![allow(non_camel_case_types)] -use std::error::Error; -use spacetimedb::{log, ReducerContext, Table}; - -#[spacetimedb::table(name = person_u64)] -pub struct Person_u64 { - #[auto_inc] - #[unique] - key_col: u64, - #[unique] - name: String, -} - -#[spacetimedb::reducer] -pub fn add_new_u64(ctx: &ReducerContext, name: String) -> Result<(), Box> { - let value = ctx.db.person_u64().try_insert(Person_u64 { key_col: 0, name })?; - log::info!("Assigned Value: {} -> {}", value.key_col, value.name); - Ok(()) -} - -#[spacetimedb::reducer] -pub fn update_u64(ctx: &ReducerContext, name: String, new_id: u64) { - ctx.db.person_u64().name().delete(&name); - let _value = ctx.db.person_u64().insert(Person_u64 { key_col: new_id, name }); -} - -#[spacetimedb::reducer] -pub fn say_hello_u64(ctx: &ReducerContext) { - for person in ctx.db.person_u64().iter() { - log::info!("Hello, {}:{}!", person.key_col, person.name); - } - log::info!("Hello, World!"); -} diff --git a/crates/smoketests/modules/autoinc-unique/Cargo.toml b/crates/smoketests/modules/autoinc-unique/Cargo.toml new file mode 100644 index 00000000000..2e0bf150d98 --- /dev/null +++ b/crates/smoketests/modules/autoinc-unique/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "smoketest-module-autoinc-unique" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb = { path = "../../../../crates/bindings" } +log = "0.4" +paste = "1.0" diff --git a/crates/smoketests/modules/autoinc-unique/src/lib.rs b/crates/smoketests/modules/autoinc-unique/src/lib.rs new file mode 100644 index 00000000000..34eb522981d --- /dev/null +++ b/crates/smoketests/modules/autoinc-unique/src/lib.rs @@ -0,0 +1,43 @@ +#![allow(non_camel_case_types)] +use spacetimedb::{log, ReducerContext, Table}; +use std::error::Error; + +macro_rules! autoinc_unique { + ($($ty:ident),*) => { + $( + paste::paste! { + #[spacetimedb::table(name = [])] + pub struct [] { + #[auto_inc] + #[unique] + key_col: $ty, + #[unique] + name: String, + } + + #[spacetimedb::reducer] + pub fn [](ctx: &ReducerContext, name: String) -> Result<(), Box> { + let value = ctx.db.[]().try_insert([] { key_col: 0, name })?; + log::info!("Assigned Value: {} -> {}", value.key_col, value.name); + Ok(()) + } + + #[spacetimedb::reducer] + pub fn [](ctx: &ReducerContext, name: String, new_id: $ty) { + ctx.db.[]().name().delete(&name); + let _value = ctx.db.[]().insert([] { key_col: new_id, name }); + } + + #[spacetimedb::reducer] + pub fn [](ctx: &ReducerContext) { + for person in ctx.db.[]().iter() { + log::info!("Hello, {}:{}!", person.key_col, person.name); + } + log::info!("Hello, World!"); + } + } + )* + }; +} + +autoinc_unique!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128); diff --git a/crates/smoketests/tests/smoketests/auto_inc.rs b/crates/smoketests/tests/smoketests/auto_inc.rs index 03800e4363e..2cbfe14baa0 100644 --- a/crates/smoketests/tests/smoketests/auto_inc.rs +++ b/crates/smoketests/tests/smoketests/auto_inc.rs @@ -1,208 +1,78 @@ //! Auto-increment tests translated from smoketests/tests/auto_inc.py -//! -//! This is a simplified version that tests representative integer types -//! rather than all 10 types in the Python version. use spacetimedb_smoketests::Smoketest; -#[test] -fn test_autoinc_u32() { - let test = Smoketest::builder().precompiled_module("autoinc-basic-u32").build(); - - test.call("add_u32", &[r#""Robert""#, "1"]).unwrap(); - test.call("add_u32", &[r#""Julie""#, "2"]).unwrap(); - test.call("add_u32", &[r#""Samantha""#, "3"]).unwrap(); - test.call("say_hello_u32", &[]).unwrap(); - - let logs = test.logs(4).unwrap(); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 3:Samantha!")), - "Expected 'Hello, 3:Samantha!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 2:Julie!")), - "Expected 'Hello, 2:Julie!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 1:Robert!")), - "Expected 'Hello, 1:Robert!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, World!")), - "Expected 'Hello, World!' in logs, got: {:?}", - logs - ); -} +const INT_TYPES: &[&str] = &["u8", "u16", "u32", "u64", "u128", "i8", "i16", "i32", "i64", "i128"]; #[test] -fn test_autoinc_u64() { - let test = Smoketest::builder().precompiled_module("autoinc-basic-u64").build(); - - test.call("add_u64", &[r#""Robert""#, "1"]).unwrap(); - test.call("add_u64", &[r#""Julie""#, "2"]).unwrap(); - test.call("add_u64", &[r#""Samantha""#, "3"]).unwrap(); - test.call("say_hello_u64", &[]).unwrap(); - - let logs = test.logs(4).unwrap(); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 3:Samantha!")), - "Expected 'Hello, 3:Samantha!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 2:Julie!")), - "Expected 'Hello, 2:Julie!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 1:Robert!")), - "Expected 'Hello, 1:Robert!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, World!")), - "Expected 'Hello, World!' in logs, got: {:?}", - logs - ); +fn test_autoinc_basic() { + let test = Smoketest::builder().precompiled_module("autoinc-basic").build(); + + for int_ty in INT_TYPES { + test.call(&format!("add_{int_ty}"), &[r#""Robert""#, "1"]).unwrap(); + test.call(&format!("add_{int_ty}"), &[r#""Julie""#, "2"]).unwrap(); + test.call(&format!("add_{int_ty}"), &[r#""Samantha""#, "3"]).unwrap(); + test.call(&format!("say_hello_{int_ty}"), &[]).unwrap(); + + let logs = test.logs(4).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 3:Samantha!")), + "[{int_ty}] Expected 'Hello, 3:Samantha!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 2:Julie!")), + "[{int_ty}] Expected 'Hello, 2:Julie!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 1:Robert!")), + "[{int_ty}] Expected 'Hello, 1:Robert!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, World!")), + "[{int_ty}] Expected 'Hello, World!' in logs, got: {:?}", + logs + ); + } } #[test] -fn test_autoinc_i32() { - let test = Smoketest::builder().precompiled_module("autoinc-basic-i32").build(); - - test.call("add_i32", &[r#""Robert""#, "1"]).unwrap(); - test.call("add_i32", &[r#""Julie""#, "2"]).unwrap(); - test.call("add_i32", &[r#""Samantha""#, "3"]).unwrap(); - test.call("say_hello_i32", &[]).unwrap(); - - let logs = test.logs(4).unwrap(); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 3:Samantha!")), - "Expected 'Hello, 3:Samantha!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 2:Julie!")), - "Expected 'Hello, 2:Julie!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 1:Robert!")), - "Expected 'Hello, 1:Robert!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, World!")), - "Expected 'Hello, World!' in logs, got: {:?}", - logs - ); -} - -#[test] -fn test_autoinc_i64() { - let test = Smoketest::builder().precompiled_module("autoinc-basic-i64").build(); - - test.call("add_i64", &[r#""Robert""#, "1"]).unwrap(); - test.call("add_i64", &[r#""Julie""#, "2"]).unwrap(); - test.call("add_i64", &[r#""Samantha""#, "3"]).unwrap(); - test.call("say_hello_i64", &[]).unwrap(); - - let logs = test.logs(4).unwrap(); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 3:Samantha!")), - "Expected 'Hello, 3:Samantha!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 2:Julie!")), - "Expected 'Hello, 2:Julie!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 1:Robert!")), - "Expected 'Hello, 1:Robert!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, World!")), - "Expected 'Hello, World!' in logs, got: {:?}", - logs - ); -} - -#[test] -fn test_autoinc_unique_u64() { - let test = Smoketest::builder().precompiled_module("autoinc-unique-u64").build(); - - // Insert Robert with explicit id 2 - test.call("update_u64", &[r#""Robert""#, "2"]).unwrap(); - - // Auto-inc should assign id 1 to Success - test.call("add_new_u64", &[r#""Success""#]).unwrap(); - - // Auto-inc tries to assign id 2, but Robert already has it - should fail - let result = test.call("add_new_u64", &[r#""Failure""#]); - assert!( - result.is_err(), - "Expected add_new to fail due to unique constraint violation" - ); - - test.call("say_hello_u64", &[]).unwrap(); - - let logs = test.logs(4).unwrap(); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 2:Robert!")), - "Expected 'Hello, 2:Robert!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 1:Success!")), - "Expected 'Hello, 1:Success!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, World!")), - "Expected 'Hello, World!' in logs, got: {:?}", - logs - ); -} - -#[test] -fn test_autoinc_unique_i64() { - let test = Smoketest::builder().precompiled_module("autoinc-unique-i64").build(); - - // Insert Robert with explicit id 2 - test.call("update_i64", &[r#""Robert""#, "2"]).unwrap(); - - // Auto-inc should assign id 1 to Success - test.call("add_new_i64", &[r#""Success""#]).unwrap(); - - // Auto-inc tries to assign id 2, but Robert already has it - should fail - let result = test.call("add_new_i64", &[r#""Failure""#]); - assert!( - result.is_err(), - "Expected add_new to fail due to unique constraint violation" - ); - - test.call("say_hello_i64", &[]).unwrap(); - - let logs = test.logs(4).unwrap(); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 2:Robert!")), - "Expected 'Hello, 2:Robert!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, 1:Success!")), - "Expected 'Hello, 1:Success!' in logs, got: {:?}", - logs - ); - assert!( - logs.iter().any(|msg| msg.contains("Hello, World!")), - "Expected 'Hello, World!' in logs, got: {:?}", - logs - ); +fn test_autoinc_unique() { + let test = Smoketest::builder().precompiled_module("autoinc-unique").build(); + + for int_ty in INT_TYPES { + // Insert Robert with explicit id 2 + test.call(&format!("update_{int_ty}"), &[r#""Robert""#, "2"]).unwrap(); + + // Auto-inc should assign id 1 to Success + test.call(&format!("add_new_{int_ty}"), &[r#""Success""#]).unwrap(); + + // Auto-inc tries to assign id 2, but Robert already has it - should fail + let result = test.call(&format!("add_new_{int_ty}"), &[r#""Failure""#]); + assert!( + result.is_err(), + "[{int_ty}] Expected add_new to fail due to unique constraint violation" + ); + + test.call(&format!("say_hello_{int_ty}"), &[]).unwrap(); + + let logs = test.logs(4).unwrap(); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 2:Robert!")), + "[{int_ty}] Expected 'Hello, 2:Robert!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, 1:Success!")), + "[{int_ty}] Expected 'Hello, 1:Success!' in logs, got: {:?}", + logs + ); + assert!( + logs.iter().any(|msg| msg.contains("Hello, World!")), + "[{int_ty}] Expected 'Hello, World!' in logs, got: {:?}", + logs + ); + } } From 7385262c60d5512dd80a10842cd1a76422a34be3 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 12:00:54 -0500 Subject: [PATCH 064/118] Fix confirmed_reads test race condition Use sql_confirmed() instead of sql() for the SQL INSERT in the confirmed subscription test. The confirmed subscription only sends updates after the transaction is durable, so using sql_confirmed() ensures the INSERT is durable before we start collecting updates. --- crates/smoketests/tests/smoketests/confirmed_reads.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/smoketests/tests/smoketests/confirmed_reads.rs b/crates/smoketests/tests/smoketests/confirmed_reads.rs index 7dedad1a2f5..d30d1e5776c 100644 --- a/crates/smoketests/tests/smoketests/confirmed_reads.rs +++ b/crates/smoketests/tests/smoketests/confirmed_reads.rs @@ -19,8 +19,9 @@ fn test_confirmed_reads_receive_updates() { // Insert via reducer test.call("add", &["Horst"]).unwrap(); - // Insert via SQL - test.sql("INSERT INTO person (name) VALUES ('Egon')").unwrap(); + // Insert via SQL (use sql_confirmed to ensure durability before continuing, + // since the confirmed subscription won't send updates until durable) + test.sql_confirmed("INSERT INTO person (name) VALUES ('Egon')").unwrap(); // Collect updates let events = sub.collect().unwrap(); From 45af2bdba9e7328421e27717ec495378316ceca1 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 12:39:30 -0500 Subject: [PATCH 065/118] Remove "moved from" comments in CLI test files --- crates/cli/src/subcommands/describe.rs | 1 + crates/smoketests/tests/smoketests/cli/dev.rs | 2 +- crates/smoketests/tests/smoketests/cli/mod.rs | 1 - crates/smoketests/tests/smoketests/cli/publish.rs | 2 +- crates/smoketests/tests/smoketests/cli/server.rs | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/cli/src/subcommands/describe.rs b/crates/cli/src/subcommands/describe.rs index e271e636262..4659c2f78f3 100644 --- a/crates/cli/src/subcommands/describe.rs +++ b/crates/cli/src/subcommands/describe.rs @@ -88,6 +88,7 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error None => sats_to_json(&module_def)?, }; + // TODO: validate the JSON output println!("{json}"); } else { // TODO: human-readable API diff --git a/crates/smoketests/tests/smoketests/cli/dev.rs b/crates/smoketests/tests/smoketests/cli/dev.rs index 3447379353a..012d513212a 100644 --- a/crates/smoketests/tests/smoketests/cli/dev.rs +++ b/crates/smoketests/tests/smoketests/cli/dev.rs @@ -1,4 +1,4 @@ -//! CLI dev command tests moved from crates/cli/tests/dev.rs +//! CLI dev command tests use predicates::prelude::*; use spacetimedb_guard::ensure_binaries_built; diff --git a/crates/smoketests/tests/smoketests/cli/mod.rs b/crates/smoketests/tests/smoketests/cli/mod.rs index 3b9e4b2b172..9d88f6e0820 100644 --- a/crates/smoketests/tests/smoketests/cli/mod.rs +++ b/crates/smoketests/tests/smoketests/cli/mod.rs @@ -1,4 +1,3 @@ -// CLI integration tests moved from crates/cli/tests/ pub mod dev; pub mod publish; pub mod server; diff --git a/crates/smoketests/tests/smoketests/cli/publish.rs b/crates/smoketests/tests/smoketests/cli/publish.rs index 5bdc1d69053..6613b35aa27 100644 --- a/crates/smoketests/tests/smoketests/cli/publish.rs +++ b/crates/smoketests/tests/smoketests/cli/publish.rs @@ -1,4 +1,4 @@ -//! CLI publish command tests moved from crates/cli/tests/publish.rs +//! CLI publish command tests use spacetimedb_guard::{ensure_binaries_built, SpacetimeDbGuard}; use std::process::Command; diff --git a/crates/smoketests/tests/smoketests/cli/server.rs b/crates/smoketests/tests/smoketests/cli/server.rs index b04653836f8..acfdcbf00bc 100644 --- a/crates/smoketests/tests/smoketests/cli/server.rs +++ b/crates/smoketests/tests/smoketests/cli/server.rs @@ -1,4 +1,4 @@ -//! CLI server command tests moved from crates/cli/tests/server.rs +//! CLI server command tests use spacetimedb_guard::{ensure_binaries_built, SpacetimeDbGuard}; use std::process::Command; From c1f53e93b46e0bd3b666a6b603dd2ab276aa7b2a Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 13:09:23 -0500 Subject: [PATCH 066/118] Add basic-rs template to workspace and improve smoketests - Convert basic-rs template from crates.io deps to workspace deps (spacetime init will convert them back for users) - Add basic-rs template to workspace members in root Cargo.toml - Rename basic-rs package to avoid collision with sdk-test-connect-disconnect - Simplify default_module_clippy test to run both templates in place - Add missing st_client table verification to client_disconnected test --- Cargo.lock | 8 ++++ Cargo.toml | 1 + .../smoketests/client_connection_errors.rs | 8 ++++ .../tests/smoketests/default_module_clippy.rs | 45 +++++++++++++++---- templates/basic-rs/spacetimedb/Cargo.toml | 6 +-- 5 files changed, 57 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cf1dff65ca8..41007453cba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -444,6 +444,14 @@ dependencies = [ "vsimd", ] +[[package]] +name = "basic-rs-template-module" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb 1.11.3", +] + [[package]] name = "benchmarks-module" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index f49f42bd2d8..09b00bdf1d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ members = [ "modules/keynote-benchmarks", "modules/perf-test", "modules/module-test", + "templates/basic-rs/spacetimedb", "templates/chat-console-rs/spacetimedb", "modules/sdk-test", "modules/sdk-test-connect-disconnect", diff --git a/crates/smoketests/tests/smoketests/client_connection_errors.rs b/crates/smoketests/tests/smoketests/client_connection_errors.rs index f8f28fda385..edf72879ca4 100644 --- a/crates/smoketests/tests/smoketests/client_connection_errors.rs +++ b/crates/smoketests/tests/smoketests/client_connection_errors.rs @@ -47,4 +47,12 @@ fn test_client_disconnected_error_still_deletes_st_client() { "Expected disconnect panic message in logs: {:?}", logs ); + + // Verify st_client table is empty (row was deleted despite the panic) + let sql_out = test.sql("SELECT * FROM st_client").unwrap(); + assert!( + sql_out.contains("identity | connection_id") && !sql_out.contains("0x"), + "Expected st_client table to be empty, got: {}", + sql_out + ); } diff --git a/crates/smoketests/tests/smoketests/default_module_clippy.rs b/crates/smoketests/tests/smoketests/default_module_clippy.rs index 4af80f3aa4d..96120806fcf 100644 --- a/crates/smoketests/tests/smoketests/default_module_clippy.rs +++ b/crates/smoketests/tests/smoketests/default_module_clippy.rs @@ -1,24 +1,53 @@ //! Tests translated from smoketests/tests/default_module_clippy.py +//! +//! These tests verify that the Rust module templates have no clippy warnings. -use spacetimedb_smoketests::Smoketest; +use std::path::PathBuf; use std::process::Command; -/// Ensure that the default rust module has no clippy errors or warnings -#[test] -fn test_default_module_clippy_check() { - // Build a smoketest with the default module code (no custom code) - let test = Smoketest::builder().autopublish(false).build(); +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .to_path_buf() +} + +/// Run clippy on a template's spacetimedb module directory. +/// Both templates use workspace dependencies, so they can be checked in place. +fn check_template_clippy(template_name: &str) { + let template_module_dir = workspace_root().join(format!("templates/{}/spacetimedb", template_name)); + + assert!( + template_module_dir.exists(), + "Template module directory does not exist: {}", + template_module_dir.display() + ); let output = Command::new("cargo") .args(["clippy", "--", "-Dwarnings"]) - .current_dir(test.project_dir.path()) + .current_dir(&template_module_dir) .output() .expect("Failed to run cargo clippy"); assert!( output.status.success(), - "Default module should have no clippy warnings:\nstdout: {}\nstderr: {}", + "Template '{}' should have no clippy warnings:\nstdout: {}\nstderr: {}", + template_name, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } + +/// Ensure that the basic-rs template module has no clippy errors or warnings +#[test] +fn test_basic_rs_template_clippy() { + check_template_clippy("basic-rs"); +} + +/// Ensure that the chat-console-rs template module has no clippy errors or warnings +#[test] +fn test_chat_console_rs_template_clippy() { + check_template_clippy("chat-console-rs"); +} diff --git a/templates/basic-rs/spacetimedb/Cargo.toml b/templates/basic-rs/spacetimedb/Cargo.toml index 271b883365e..bb14648bd0c 100644 --- a/templates/basic-rs/spacetimedb/Cargo.toml +++ b/templates/basic-rs/spacetimedb/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "spacetime-module" +name = "basic-rs-template-module" version = "0.1.0" edition = "2021" @@ -9,5 +9,5 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -spacetimedb = "1.11.*" -log = "0.4" +spacetimedb = { path = "../../../crates/bindings" } +log.workspace = true From 7674202d4133fb6b37853a3fa3301372fd652ef1 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 13:22:20 -0500 Subject: [PATCH 067/118] Add test_replace_names test and auth support for API calls - Add api_call_json method for JSON API calls with Content-Type header - Add Authorization header with Bearer token to all API calls - Add test_replace_names test from domains.py (tests PUT /v1/database/{name}/names) --- crates/smoketests/src/lib.rs | 23 ++++++++++- crates/smoketests/tests/smoketests/domains.rs | 38 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index f7e3854382f..4f0de36a444 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -978,6 +978,22 @@ log = "0.4" /// Makes an HTTP API call with an optional request body. pub fn api_call_with_body(&self, method: &str, path: &str, body: Option<&[u8]>) -> Result { + self.api_call_internal(method, path, body, "") + } + + /// Makes an HTTP API call with a JSON body. + pub fn api_call_json(&self, method: &str, path: &str, json_body: &str) -> Result { + self.api_call_internal(method, path, Some(json_body.as_bytes()), "Content-Type: application/json\r\n") + } + + /// Internal HTTP API call implementation. + fn api_call_internal( + &self, + method: &str, + path: &str, + body: Option<&[u8]>, + extra_headers: &str, + ) -> Result { use std::io::{Read, Write}; use std::net::TcpStream; @@ -991,11 +1007,14 @@ log = "0.4" let mut stream = TcpStream::connect(host_port).context("Failed to connect to server")?; stream.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok(); + // Get auth token + let token = self.read_token()?; + // Build HTTP request let content_length = body.map(|b| b.len()).unwrap_or(0); let request = format!( - "{} {} HTTP/1.1\r\nHost: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - method, path, host_port, content_length + "{} {} HTTP/1.1\r\nHost: {}\r\nContent-Length: {}\r\nAuthorization: Bearer {}\r\n{}Connection: close\r\n\r\n", + method, path, host_port, content_length, token, extra_headers ); stream.write_all(request.as_bytes())?; diff --git a/crates/smoketests/tests/smoketests/domains.rs b/crates/smoketests/tests/smoketests/domains.rs index df845edf63d..6afdec37930 100644 --- a/crates/smoketests/tests/smoketests/domains.rs +++ b/crates/smoketests/tests/smoketests/domains.rs @@ -76,3 +76,41 @@ fn test_set_to_existing_name() { "Expected rename to fail when target name is already in use" ); } + +/// Test that we can rename to a list of names via the API +#[test] +fn test_replace_names() { + let mut test = Smoketest::builder().autopublish(false).build(); + + let orig_name = format!("test-db-{}", std::process::id()); + let alt_name1 = format!("test-db-{}-alt1", std::process::id()); + let alt_name2 = format!("test-db-{}-alt2", std::process::id()); + test.publish_module_named(&orig_name, false).unwrap(); + + // Use the API to replace names + let json_body = format!(r#"["{}","{}"]"#, alt_name1, alt_name2); + let response = test + .api_call_json("PUT", &format!("/v1/database/{}/names", orig_name), &json_body) + .unwrap(); + assert!( + response.status_code == 200, + "Expected 200 status, got {}: {}", + response.status_code, + String::from_utf8_lossy(&response.body) + ); + + // Use logs to check that name resolution works + test.spacetime(&["logs", "--server", &test.server_url, &alt_name1]) + .unwrap(); + test.spacetime(&["logs", "--server", &test.server_url, &alt_name2]) + .unwrap(); + + // Original name should no longer work + let result = test.spacetime(&["logs", "--server", &test.server_url, &orig_name]); + assert!(result.is_err(), "Expected logs to fail for original name after rename"); + + // Restore orig name so the database gets deleted on cleanup + let json_body = format!(r#"["{}"]"#, orig_name); + test.api_call_json("PUT", &format!("/v1/database/{}/names", alt_name1), &json_body) + .unwrap(); +} From 06669e5c36d5830cae37ee5b5f2f2f9f7d0d6828 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 13:30:04 -0500 Subject: [PATCH 068/118] Use precompiled modules-breaking module instead of inline code - Add modules-breaking to precompiled modules in Cargo.toml - Update test_module_update to use precompiled module --- crates/smoketests/modules/Cargo.toml | 2 +- crates/smoketests/tests/smoketests/modules.rs | 16 ++-------------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/crates/smoketests/modules/Cargo.toml b/crates/smoketests/modules/Cargo.toml index 24e38a98191..44b6637f2ac 100644 --- a/crates/smoketests/modules/Cargo.toml +++ b/crates/smoketests/modules/Cargo.toml @@ -39,7 +39,7 @@ members = [ # Module lifecycle tests "describe", "modules-basic", - # "modules-breaking" - intentionally has breaking schema change, uses runtime compilation + "modules-breaking", "modules-add-table", # Index tests diff --git a/crates/smoketests/tests/smoketests/modules.rs b/crates/smoketests/tests/smoketests/modules.rs index 6609eefe283..83f71722d24 100644 --- a/crates/smoketests/tests/smoketests/modules.rs +++ b/crates/smoketests/tests/smoketests/modules.rs @@ -2,18 +2,6 @@ use spacetimedb_smoketests::Smoketest; -/// Breaking change: adds a new column to Person -const MODULE_CODE_BREAKING: &str = r#" -#[spacetimedb::table(name = person)] -pub struct Person { - #[primary_key] - #[auto_inc] - id: u64, - name: String, - age: u8, -} -"#; - /// Test publishing a module without the --delete-data option #[test] fn test_module_update() { @@ -41,8 +29,8 @@ fn test_module_update() { // Unchanged module is ok test.publish_module_named(&name, false).unwrap(); - // Changing an existing table isn't - test.write_module_code(MODULE_CODE_BREAKING).unwrap(); + // Changing an existing table isn't (adds age column to Person) + test.use_precompiled_module("modules-breaking"); let result = test.publish_module_named(&name, false); assert!(result.is_err(), "Expected publish to fail with breaking change"); let err = result.unwrap_err().to_string(); From ee141de2c94f0530fb904f5cd6e4a36951d794cd Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 13:33:53 -0500 Subject: [PATCH 069/118] Add test_upload_module_2 and test_hotswap_module tests - Add upload-module-2 precompiled module (repeating scheduled reducer) - Add hotswap-basic and hotswap-updated precompiled modules - Add test_upload_module_2 to verify repeating reducers work - Add test_hotswap_module to verify module updates with active subscriptions --- crates/smoketests/modules/Cargo.lock | 32 +++++++ crates/smoketests/modules/Cargo.toml | 3 + .../modules/hotswap-basic/Cargo.toml | 11 +++ .../modules/hotswap-basic/src/lib.rs | 14 ++++ .../modules/hotswap-updated/Cargo.toml | 11 +++ .../modules/hotswap-updated/src/lib.rs | 25 ++++++ .../modules/upload-module-2/Cargo.toml | 11 +++ .../modules/upload-module-2/src/lib.rs | 24 ++++++ crates/smoketests/tests/smoketests/modules.rs | 83 +++++++++++++++++++ 9 files changed, 214 insertions(+) create mode 100644 crates/smoketests/modules/hotswap-basic/Cargo.toml create mode 100644 crates/smoketests/modules/hotswap-basic/src/lib.rs create mode 100644 crates/smoketests/modules/hotswap-updated/Cargo.toml create mode 100644 crates/smoketests/modules/hotswap-updated/src/lib.rs create mode 100644 crates/smoketests/modules/upload-module-2/Cargo.toml create mode 100644 crates/smoketests/modules/upload-module-2/src/lib.rs diff --git a/crates/smoketests/modules/Cargo.lock b/crates/smoketests/modules/Cargo.lock index 45dd72e3c46..5ba15343056 100644 --- a/crates/smoketests/modules/Cargo.lock +++ b/crates/smoketests/modules/Cargo.lock @@ -633,6 +633,22 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "smoketest-module-hotswap-basic" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-hotswap-updated" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + [[package]] name = "smoketest-module-module-nested-op" version = "0.1.0" @@ -657,6 +673,14 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "smoketest-module-modules-breaking" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + [[package]] name = "smoketest-module-namespaces" version = "0.1.0" @@ -769,6 +793,14 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "smoketest-module-upload-module-2" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + [[package]] name = "smoketest-module-views-basic" version = "0.1.0" diff --git a/crates/smoketests/modules/Cargo.toml b/crates/smoketests/modules/Cargo.toml index 44b6637f2ac..03c144fc6b7 100644 --- a/crates/smoketests/modules/Cargo.toml +++ b/crates/smoketests/modules/Cargo.toml @@ -41,6 +41,9 @@ members = [ "modules-basic", "modules-breaking", "modules-add-table", + "upload-module-2", + "hotswap-basic", + "hotswap-updated", # Index tests "add-remove-index", diff --git a/crates/smoketests/modules/hotswap-basic/Cargo.toml b/crates/smoketests/modules/hotswap-basic/Cargo.toml new file mode 100644 index 00000000000..e8d3e0d5fa4 --- /dev/null +++ b/crates/smoketests/modules/hotswap-basic/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "smoketest-module-hotswap-basic" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/hotswap-basic/src/lib.rs b/crates/smoketests/modules/hotswap-basic/src/lib.rs new file mode 100644 index 00000000000..0933c72c471 --- /dev/null +++ b/crates/smoketests/modules/hotswap-basic/src/lib.rs @@ -0,0 +1,14 @@ +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = person, public)] +pub struct Person { + #[primary_key] + #[auto_inc] + id: u64, + name: String, +} + +#[spacetimedb::reducer] +pub fn add_person(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { id: 0, name }); +} diff --git a/crates/smoketests/modules/hotswap-updated/Cargo.toml b/crates/smoketests/modules/hotswap-updated/Cargo.toml new file mode 100644 index 00000000000..14d17c5f2c7 --- /dev/null +++ b/crates/smoketests/modules/hotswap-updated/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "smoketest-module-hotswap-updated" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/hotswap-updated/src/lib.rs b/crates/smoketests/modules/hotswap-updated/src/lib.rs new file mode 100644 index 00000000000..0a197954d83 --- /dev/null +++ b/crates/smoketests/modules/hotswap-updated/src/lib.rs @@ -0,0 +1,25 @@ +use spacetimedb::{ReducerContext, Table}; + +#[spacetimedb::table(name = person, public)] +pub struct Person { + #[primary_key] + #[auto_inc] + id: u64, + name: String, +} + +#[spacetimedb::reducer] +pub fn add_person(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { id: 0, name }); +} + +#[spacetimedb::table(name = pet, public)] +pub struct Pet { + #[primary_key] + species: String, +} + +#[spacetimedb::reducer] +pub fn add_pet(ctx: &ReducerContext, species: String) { + ctx.db.pet().insert(Pet { species }); +} diff --git a/crates/smoketests/modules/upload-module-2/Cargo.toml b/crates/smoketests/modules/upload-module-2/Cargo.toml new file mode 100644 index 00000000000..46379526226 --- /dev/null +++ b/crates/smoketests/modules/upload-module-2/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "smoketest-module-upload-module-2" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/upload-module-2/src/lib.rs b/crates/smoketests/modules/upload-module-2/src/lib.rs new file mode 100644 index 00000000000..ee897c9aad4 --- /dev/null +++ b/crates/smoketests/modules/upload-module-2/src/lib.rs @@ -0,0 +1,24 @@ +use spacetimedb::{log, duration, ReducerContext, Table, Timestamp}; + +#[spacetimedb::table(name = scheduled_message, public, scheduled(my_repeating_reducer))] +pub struct ScheduledMessage { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + scheduled_at: spacetimedb::ScheduleAt, + prev: Timestamp, +} + +#[spacetimedb::reducer(init)] +fn init(ctx: &ReducerContext) { + ctx.db.scheduled_message().insert(ScheduledMessage { + prev: ctx.timestamp, + scheduled_id: 0, + scheduled_at: duration!(100ms).into(), + }); +} + +#[spacetimedb::reducer] +pub fn my_repeating_reducer(ctx: &ReducerContext, arg: ScheduledMessage) { + log::info!("Invoked: ts={:?}, delta={:?}", ctx.timestamp, ctx.timestamp.duration_since(arg.prev)); +} diff --git a/crates/smoketests/tests/smoketests/modules.rs b/crates/smoketests/tests/smoketests/modules.rs index 83f71722d24..d82b62f3c18 100644 --- a/crates/smoketests/tests/smoketests/modules.rs +++ b/crates/smoketests/tests/smoketests/modules.rs @@ -72,3 +72,86 @@ fn test_upload_module() { assert!(logs.iter().any(|l| l.contains("Hello, Robert!"))); assert!(logs.iter().any(|l| l.contains("Hello, World!"))); } + +/// Test deploying a module with a repeating reducer and checking it runs +#[test] +fn test_upload_module_2() { + let test = Smoketest::builder().precompiled_module("upload-module-2").build(); + + // Wait for the repeating reducer to run a few times + std::thread::sleep(std::time::Duration::from_secs(2)); + let lines = test.logs(100).unwrap().iter().filter(|l| l.contains("Invoked")).count(); + + // Wait more and check that count increased + std::thread::sleep(std::time::Duration::from_secs(4)); + let new_lines = test.logs(100).unwrap().iter().filter(|l| l.contains("Invoked")).count(); + + assert!( + lines < new_lines, + "Expected more invocations after waiting, got {} then {}", + lines, + new_lines + ); +} + +/// Test hotswapping modules while a subscription is active +#[test] +fn test_hotswap_module() { + let mut test = Smoketest::builder() + .precompiled_module("hotswap-basic") + .autopublish(false) + .build(); + + let name = format!("test-db-{}", std::process::id()); + + // Publish initial module and subscribe to all + test.publish_module_named(&name, false).unwrap(); + let sub = test.subscribe_background(&["SELECT * FROM *"], 2).unwrap(); + + // Trigger event on the subscription + test.call("add_person", &["Horst"]).unwrap(); + + // Update the module (adds Pet table) + test.use_precompiled_module("hotswap-updated"); + test.publish_module_named(&name, false).unwrap(); + + // Assert that the module was updated + test.call("add_pet", &["Turtle"]).unwrap(); + // And trigger another event on the subscription + test.call("add_person", &["Cindy"]).unwrap(); + + // Note that 'SELECT * FROM *' does NOT get refreshed to include the + // new table (this is a known limitation). + let updates = sub.collect().unwrap(); + + // Check that we got updates for both person inserts + assert_eq!(updates.len(), 2, "Expected 2 updates, got {:?}", updates); + + // First update should be Horst + let first = &updates[0]; + assert!( + first.get("person").is_some(), + "Expected person table in first update: {:?}", + first + ); + let inserts = &first["person"]["inserts"]; + assert!( + inserts.as_array().unwrap().iter().any(|r| r["name"] == "Horst"), + "Expected Horst in first update: {:?}", + first + ); + + // Second update should be Cindy + let second = &updates[1]; + assert!( + second.get("person").is_some(), + "Expected person table in second update: {:?}", + second + ); + let inserts = &second["person"]["inserts"]; + assert!( + inserts.as_array().unwrap().iter().any(|r| r["name"] == "Cindy"), + "Expected Cindy in second update: {:?}", + second + ); +} From bfc0252253ceac39877aa3004d42c8f5a49284c4 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 14:08:43 -0500 Subject: [PATCH 070/118] Use precompiled namespaces module instead of inline code Point --project-path to the precompiled module source directory for spacetime generate to detect the language. --- .../smoketests/tests/smoketests/namespaces.rs | 103 +++++++----------- 1 file changed, 42 insertions(+), 61 deletions(-) diff --git a/crates/smoketests/tests/smoketests/namespaces.rs b/crates/smoketests/tests/smoketests/namespaces.rs index fa02b338a8a..030a9c2cc37 100644 --- a/crates/smoketests/tests/smoketests/namespaces.rs +++ b/crates/smoketests/tests/smoketests/namespaces.rs @@ -1,45 +1,18 @@ //! Namespace tests translated from smoketests/tests/namespaces.py -//! -//! These tests use `module_code()` instead of `precompiled_module()` because -//! they use `spacetime generate --project-path` which requires a Cargo.toml -//! to detect the module language. They don't actually compile the module. use spacetimedb_smoketests::Smoketest; use std::fs; -use std::path::Path; - -/// Module code for namespace tests -const MODULE_CODE: &str = r#" -use spacetimedb::{ReducerContext, Table}; - -#[spacetimedb::table(name = person, public)] -pub struct Person { - name: String, +use std::path::{Path, PathBuf}; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .to_path_buf() } -#[spacetimedb::reducer(init)] -pub fn init(_ctx: &ReducerContext) {} - -#[spacetimedb::reducer(client_connected)] -pub fn identity_connected(_ctx: &ReducerContext) {} - -#[spacetimedb::reducer(client_disconnected)] -pub fn identity_disconnected(_ctx: &ReducerContext) {} - -#[spacetimedb::reducer] -pub fn add(ctx: &ReducerContext, name: String) { - ctx.db.person().insert(Person { name }); -} - -#[spacetimedb::reducer] -pub fn say_hello(ctx: &ReducerContext) { - for person in ctx.db.person().iter() { - log::info!("Hello, {}!", person.name); - } - log::info!("Hello, World!"); -} -"#; - /// Count occurrences of a needle string in all .cs files under a directory fn count_matches(dir: &Path, needle: &str) -> usize { let mut count = 0; @@ -61,20 +34,24 @@ fn count_matches(dir: &Path, needle: &str) -> usize { /// Ensure that the default namespace is working properly #[test] fn test_spacetimedb_ns_csharp() { - let test = Smoketest::builder().module_code(MODULE_CODE).autopublish(false).build(); + let _test = Smoketest::builder() + .precompiled_module("namespaces") + .autopublish(false) + .build(); let tmpdir = tempfile::tempdir().expect("Failed to create temp dir"); - let project_path = test.project_dir.path().to_str().unwrap(); - - test.spacetime(&[ - "generate", - "--out-dir", - tmpdir.path().to_str().unwrap(), - "--lang=csharp", - "--project-path", - project_path, - ]) - .unwrap(); + let project_path = workspace_root().join("crates/smoketests/modules/namespaces"); + + _test + .spacetime(&[ + "generate", + "--out-dir", + tmpdir.path().to_str().unwrap(), + "--lang=csharp", + "--project-path", + project_path.to_str().unwrap(), + ]) + .unwrap(); let namespace = "SpacetimeDB.Types"; assert_eq!( @@ -93,25 +70,29 @@ fn test_spacetimedb_ns_csharp() { /// Ensure that when a custom namespace is specified on the command line, it actually gets used in generation #[test] fn test_custom_ns_csharp() { - let test = Smoketest::builder().module_code(MODULE_CODE).autopublish(false).build(); + let _test = Smoketest::builder() + .precompiled_module("namespaces") + .autopublish(false) + .build(); let tmpdir = tempfile::tempdir().expect("Failed to create temp dir"); - let project_path = test.project_dir.path().to_str().unwrap(); + let project_path = workspace_root().join("crates/smoketests/modules/namespaces"); // Use a unique namespace name let namespace = "CustomTestNamespace"; - test.spacetime(&[ - "generate", - "--out-dir", - tmpdir.path().to_str().unwrap(), - "--lang=csharp", - "--namespace", - namespace, - "--project-path", - project_path, - ]) - .unwrap(); + _test + .spacetime(&[ + "generate", + "--out-dir", + tmpdir.path().to_str().unwrap(), + "--lang=csharp", + "--namespace", + namespace, + "--project-path", + project_path.to_str().unwrap(), + ]) + .unwrap(); assert_eq!( count_matches(tmpdir.path(), &format!("namespace {}", namespace)), From c86854dd540e3cac5b566669ccc01cd810e4c859 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 14:09:45 -0500 Subject: [PATCH 071/118] Add missing SELECT * FROM * subscription test to permissions --- .../smoketests/tests/smoketests/permissions.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/smoketests/tests/smoketests/permissions.rs b/crates/smoketests/tests/smoketests/permissions.rs index 8f3ff41b2b5..76d1a5f2919 100644 --- a/crates/smoketests/tests/smoketests/permissions.rs +++ b/crates/smoketests/tests/smoketests/permissions.rs @@ -44,6 +44,22 @@ fn test_private_table() { } }); assert_eq!(events[0], expected); + + // Subscribing to both tables returns updates for the public one only + let sub = test + .subscribe_background(&["SELECT * FROM *"], 1) + .unwrap(); + test.call("do_thing", &["howdy"]).unwrap(); + let events = sub.collect().unwrap(); + assert_eq!(events.len(), 1, "Expected 1 update, got {:?}", events); + + let expected = serde_json::json!({ + "common_knowledge": { + "deletes": [], + "inserts": [{"thing": "howdy"}] + } + }); + assert_eq!(events[0], expected); } /// Ensure that you cannot delete a database that you do not own From dad9b5e66826d14d479296d16ae5a585e64b2fd7 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 14:13:49 -0500 Subject: [PATCH 072/118] Add missing permission tests: call, describe, logs, publish, replace_names - Add call_anon method for anonymous reducer calls - Add describe and describe_anon methods - Add test_call: verify anyone can call standard reducers - Add test_describe: verify anyone can describe any database - Add test_logs: verify non-owners cannot view logs - Add test_publish: verify cannot publish to database you do not own - Add test_replace_names: verify cannot replace names of database you do not own --- crates/smoketests/src/lib.rs | 46 ++++++- .../tests/smoketests/permissions.rs | 124 +++++++++++++++++- 2 files changed, 166 insertions(+), 4 deletions(-) diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 4f0de36a444..2cfd31ed447 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -864,6 +864,45 @@ log = "0.4" self.spacetime_cmd(&cmd_args) } + /// Calls a reducer anonymously (without authentication). + pub fn call_anon(&self, name: &str, args: &[&str]) -> Result { + let identity = self.database_identity.as_ref().context("No database published")?; + + let mut cmd_args = vec![ + "call", + "--anonymous", + "--server", + &self.server_url, + "--", + identity.as_str(), + name, + ]; + cmd_args.extend(args); + + self.spacetime(&cmd_args) + } + + /// Describes the database schema. + pub fn describe(&self) -> Result { + let identity = self.database_identity.as_ref().context("No database published")?; + + self.spacetime(&["describe", "--server", &self.server_url, identity.as_str()]) + } + + /// Describes the database schema anonymously (requires --json). + pub fn describe_anon(&self) -> Result { + let identity = self.database_identity.as_ref().context("No database published")?; + + self.spacetime(&[ + "describe", + "--anonymous", + "--json", + "--server", + &self.server_url, + identity.as_str(), + ]) + } + /// Executes a SQL query against the database. pub fn sql(&self, query: &str) -> Result { let identity = self.database_identity.as_ref().context("No database published")?; @@ -983,7 +1022,12 @@ log = "0.4" /// Makes an HTTP API call with a JSON body. pub fn api_call_json(&self, method: &str, path: &str, json_body: &str) -> Result { - self.api_call_internal(method, path, Some(json_body.as_bytes()), "Content-Type: application/json\r\n") + self.api_call_internal( + method, + path, + Some(json_body.as_bytes()), + "Content-Type: application/json\r\n", + ) } /// Internal HTTP API call implementation. diff --git a/crates/smoketests/tests/smoketests/permissions.rs b/crates/smoketests/tests/smoketests/permissions.rs index 76d1a5f2919..9f487118d21 100644 --- a/crates/smoketests/tests/smoketests/permissions.rs +++ b/crates/smoketests/tests/smoketests/permissions.rs @@ -1,6 +1,126 @@ //! Tests translated from smoketests/tests/permissions.py use spacetimedb_smoketests::Smoketest; +use std::path::PathBuf; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .to_path_buf() +} + +/// Ensure that anyone has the permission to call any standard reducer +#[test] +fn test_call() { + let test = Smoketest::builder().precompiled_module("modules-basic").build(); + + test.call_anon("say_hello", &[]).unwrap(); + + let logs = test.logs(10000).unwrap(); + let world_count = logs.iter().filter(|l| l.contains("World")).count(); + assert_eq!(world_count, 1, "Expected 1 'World' in logs, got {}", world_count); +} + +/// Ensure that anyone can describe any database +#[test] +fn test_describe() { + let test = Smoketest::builder().precompiled_module("modules-basic").build(); + + // Should succeed with anonymous describe + test.describe_anon().unwrap(); +} + +/// Ensure that we are not able to view the logs of a module that we don't have permission to view +#[test] +fn test_logs() { + let test = Smoketest::builder().precompiled_module("modules-basic").build(); + + // Call say_hello as owner + test.call("say_hello", &[]).unwrap(); + + // Switch to a new identity + test.new_identity().unwrap(); + + // Call say_hello as new identity (should work - reducers are public) + test.call("say_hello", &[]).unwrap(); + + // Switch to another new identity + test.new_identity().unwrap(); + + // Try to view logs - should fail as non-owner + let identity = test.database_identity.as_ref().unwrap(); + let result = test.spacetime(&["logs", "--server", &test.server_url, identity, "-n", "10000"]); + assert!(result.is_err(), "Expected logs to fail for non-owner"); +} + +/// Ensure that you cannot publish to an identity that you do not own +#[test] +fn test_publish() { + let test = Smoketest::builder().precompiled_module("modules-basic").build(); + + let identity = test.database_identity.as_ref().unwrap().clone(); + + // Switch to a new identity + test.new_identity().unwrap(); + + // Try to publish with --delete-data - should fail + let project_path = workspace_root().join("crates/smoketests/modules/modules-basic"); + let result = test.spacetime(&[ + "publish", + &identity, + "--server", + &test.server_url, + "--project-path", + project_path.to_str().unwrap(), + "--delete-data", + "--yes", + ]); + assert!( + result.is_err(), + "Expected publish with --delete-data to fail for non-owner" + ); + + // Try to publish without --delete-data - should also fail + let result = test.spacetime(&[ + "publish", + &identity, + "--server", + &test.server_url, + "--project-path", + project_path.to_str().unwrap(), + "--yes", + ]); + assert!(result.is_err(), "Expected publish to fail for non-owner"); +} + +/// Test that you can't replace names of a database you don't own +#[test] +fn test_replace_names() { + let mut test = Smoketest::builder() + .precompiled_module("modules-basic") + .autopublish(false) + .build(); + + let name = format!("test-db-{}", std::process::id()); + test.publish_module_named(&name, false).unwrap(); + + // Switch to a new identity + test.new_identity().unwrap(); + + // Try to replace names - should fail + let json_body = r#"["post", "gres"]"#; + let response = test + .api_call_json("PUT", &format!("/v1/database/{}/names", name), json_body) + .unwrap(); + assert!( + response.status_code != 200, + "Expected replace names to fail for non-owner, got status {}", + response.status_code + ); +} /// Ensure that a private table can only be queried by the database owner #[test] @@ -46,9 +166,7 @@ fn test_private_table() { assert_eq!(events[0], expected); // Subscribing to both tables returns updates for the public one only - let sub = test - .subscribe_background(&["SELECT * FROM *"], 1) - .unwrap(); + let sub = test.subscribe_background(&["SELECT * FROM *"], 1).unwrap(); test.call("do_thing", &["howdy"]).unwrap(); let events = sub.collect().unwrap(); assert_eq!(events.len(), 1, "Expected 1 update, got {:?}", events); From 017f42d17cfd2cdd0139ac06fb2815c402e595e0 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 14:27:39 -0500 Subject: [PATCH 073/118] Add missing RLS tests: BrokenRls and DisconnectRls - Add rls-no-filter and rls-with-filter precompiled modules - Add publish_module_with_options method for --break-clients flag - Add test_publish_fails_for_rls_on_private_table - Add test_rls_disconnect_if_change - Add test_rls_no_disconnect --- crates/smoketests/modules/Cargo.lock | 16 ++++ crates/smoketests/modules/Cargo.toml | 2 + .../modules/rls-no-filter/Cargo.toml | 11 +++ .../modules/rls-no-filter/src/lib.rs | 15 ++++ .../modules/rls-with-filter/Cargo.toml | 11 +++ .../modules/rls-with-filter/src/lib.rs | 19 ++++ crates/smoketests/src/lib.rs | 14 +++ crates/smoketests/tests/smoketests/rls.rs | 89 +++++++++++++++++++ 8 files changed, 177 insertions(+) create mode 100644 crates/smoketests/modules/rls-no-filter/Cargo.toml create mode 100644 crates/smoketests/modules/rls-no-filter/src/lib.rs create mode 100644 crates/smoketests/modules/rls-with-filter/Cargo.toml create mode 100644 crates/smoketests/modules/rls-with-filter/src/lib.rs diff --git a/crates/smoketests/modules/Cargo.lock b/crates/smoketests/modules/Cargo.lock index 5ba15343056..fa409a4851b 100644 --- a/crates/smoketests/modules/Cargo.lock +++ b/crates/smoketests/modules/Cargo.lock @@ -761,6 +761,22 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "smoketest-module-rls-no-filter" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-rls-with-filter" +version = "0.1.0" +dependencies = [ + "log", + "spacetimedb", +] + [[package]] name = "smoketest-module-schedule-cancel" version = "0.1.0" diff --git a/crates/smoketests/modules/Cargo.toml b/crates/smoketests/modules/Cargo.toml index 03c144fc6b7..cacef1f1e3c 100644 --- a/crates/smoketests/modules/Cargo.toml +++ b/crates/smoketests/modules/Cargo.toml @@ -20,6 +20,8 @@ members = [ # Security and permissions "rls", + "rls-no-filter", + "rls-with-filter", "permissions-private", "permissions-lifecycle", diff --git a/crates/smoketests/modules/rls-no-filter/Cargo.toml b/crates/smoketests/modules/rls-no-filter/Cargo.toml new file mode 100644 index 00000000000..ebd9975f9f1 --- /dev/null +++ b/crates/smoketests/modules/rls-no-filter/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "smoketest-module-rls-no-filter" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/rls-no-filter/src/lib.rs b/crates/smoketests/modules/rls-no-filter/src/lib.rs new file mode 100644 index 00000000000..b46197afc99 --- /dev/null +++ b/crates/smoketests/modules/rls-no-filter/src/lib.rs @@ -0,0 +1,15 @@ +use spacetimedb::{Identity, ReducerContext, Table}; + +#[spacetimedb::table(name = users, public)] +pub struct Users { + name: String, + identity: Identity, +} + +#[spacetimedb::reducer] +pub fn add_user(ctx: &ReducerContext, name: String) { + ctx.db.users().insert(Users { + name, + identity: ctx.sender, + }); +} diff --git a/crates/smoketests/modules/rls-with-filter/Cargo.toml b/crates/smoketests/modules/rls-with-filter/Cargo.toml new file mode 100644 index 00000000000..5f59d9c9650 --- /dev/null +++ b/crates/smoketests/modules/rls-with-filter/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "smoketest-module-rls-with-filter" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true +log.workspace = true diff --git a/crates/smoketests/modules/rls-with-filter/src/lib.rs b/crates/smoketests/modules/rls-with-filter/src/lib.rs new file mode 100644 index 00000000000..8a684ef82af --- /dev/null +++ b/crates/smoketests/modules/rls-with-filter/src/lib.rs @@ -0,0 +1,19 @@ +use spacetimedb::{Identity, ReducerContext, Table}; + +#[spacetimedb::table(name = users, public)] +pub struct Users { + name: String, + identity: Identity, +} + +#[spacetimedb::client_visibility_filter] +const USER_FILTER: spacetimedb::Filter = + spacetimedb::Filter::Sql("SELECT * FROM users WHERE identity = :sender"); + +#[spacetimedb::reducer] +pub fn add_user(ctx: &ReducerContext, name: String) { + ctx.db.users().insert(Users { + name, + identity: ctx.sender, + }); +} diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 2cfd31ed447..ebd9adeeadf 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -763,8 +763,18 @@ log = "0.4" self.publish_module_opts(Some(&identity), clear) } + /// Publishes the module with name, clear, and break_clients options. + pub fn publish_module_with_options(&mut self, name: &str, clear: bool, break_clients: bool) -> Result { + self.publish_module_internal(Some(name), clear, break_clients) + } + /// Internal helper for publishing with options. fn publish_module_opts(&mut self, name: Option<&str>, clear: bool) -> Result { + self.publish_module_internal(name, clear, false) + } + + /// Internal helper for publishing with all options. + fn publish_module_internal(&mut self, name: Option<&str>, clear: bool, break_clients: bool) -> Result { let start = Instant::now(); // Determine the WASM path - either precompiled or build it @@ -818,6 +828,10 @@ log = "0.4" args.push("--clear-database"); } + if break_clients { + args.push("--break-clients"); + } + let name_owned; if let Some(n) = name { name_owned = n.to_string(); diff --git a/crates/smoketests/tests/smoketests/rls.rs b/crates/smoketests/tests/smoketests/rls.rs index 647b8d06b34..4df27d225dd 100644 --- a/crates/smoketests/tests/smoketests/rls.rs +++ b/crates/smoketests/tests/smoketests/rls.rs @@ -30,3 +30,92 @@ fn test_rls_rules() { ------"#, ); } + +/// Module code with RLS on a private table (intentionally broken) +const MODULE_CODE_BROKEN_RLS: &str = r#" +use spacetimedb::{client_visibility_filter, Filter, Identity}; + +#[spacetimedb::table(name = user)] +pub struct User { + identity: Identity, +} + +#[client_visibility_filter] +const PERSON_FILTER: Filter = Filter::Sql("SELECT * FROM \"user\" WHERE identity = :sender"); +"#; + +/// Tests that publishing an RLS rule on a private table fails +#[test] +fn test_publish_fails_for_rls_on_private_table() { + let mut test = Smoketest::builder() + .module_code(MODULE_CODE_BROKEN_RLS) + .autopublish(false) + .build(); + + let name = format!("test-db-{}", std::process::id()); + + // Publishing should fail because RLS is on a private table + let result = test.publish_module_named(&name, false); + assert!(result.is_err(), "Expected publish to fail for RLS on private table"); +} + +/// Tests that changing the RLS rules disconnects existing clients +#[test] +fn test_rls_disconnect_if_change() { + let mut test = Smoketest::builder() + .precompiled_module("rls-no-filter") + .autopublish(false) + .build(); + + let name = format!("test-db-{}", std::process::id()); + + // Initial publish without RLS + test.publish_module_named(&name, false).unwrap(); + + // Now re-publish with RLS added (requires --break-clients) + test.use_precompiled_module("rls-with-filter"); + test.publish_module_with_options(&name, false, true).unwrap(); + + // Check the row-level SQL filter is added correctly + test.assert_sql( + "SELECT sql FROM st_row_level_security", + r#" sql +------------------------------------------------ + "SELECT * FROM users WHERE identity = :sender""#, + ); + + let logs = test.logs(100).unwrap(); + + // Validate disconnect + schema migration logs + assert!( + logs.iter().any(|l| l.contains("Disconnecting all users")), + "Expected 'Disconnecting all users' in logs: {:?}", + logs + ); +} + +/// Tests that not changing the RLS rules does not disconnect existing clients +#[test] +fn test_rls_no_disconnect() { + let mut test = Smoketest::builder() + .precompiled_module("rls-with-filter") + .autopublish(false) + .build(); + + let name = format!("test-db-{}", std::process::id()); + + // Initial publish with RLS + test.publish_module_named(&name, false).unwrap(); + + // Re-publish the same module (no RLS change) + test.publish_module_named(&name, false).unwrap(); + + let logs = test.logs(100).unwrap(); + + // Validate no disconnect logs + assert!( + !logs.iter().any(|l| l.contains("Disconnecting all users")), + "Expected no 'Disconnecting all users' in logs: {:?}", + logs + ); +} From 7f29dfce97b0106377d79d5809492a417385a256 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 27 Jan 2026 14:33:09 -0500 Subject: [PATCH 074/118] Hold subscription across add call in add_remove_index test Match Python behavior: subscribe expecting 1 update, call add, then collect subscription results to verify the update is received. --- .../tests/smoketests/add_remove_index.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/crates/smoketests/tests/smoketests/add_remove_index.rs b/crates/smoketests/tests/smoketests/add_remove_index.rs index 22484c4834c..a77d101331a 100644 --- a/crates/smoketests/tests/smoketests/add_remove_index.rs +++ b/crates/smoketests/tests/smoketests/add_remove_index.rs @@ -29,16 +29,11 @@ fn test_add_then_remove_index() { test.use_precompiled_module("add-remove-index-indexed"); test.publish_module_named(&name, false).unwrap(); - // Subscription should work now (n=0 just verifies the query is accepted) - let result = test.subscribe(&[JOIN_QUERY], 0); - assert!( - result.is_ok(), - "Expected subscription to succeed with indices, got: {:?}", - result.err() - ); - - // Verify call works too - test.call("add", &[]).unwrap(); + // Subscribe and hold across the call, then collect results + let sub = test.subscribe_background(&[JOIN_QUERY], 1).unwrap(); + test.call_anon("add", &[]).unwrap(); + let results = sub.collect().unwrap(); + assert_eq!(results.len(), 1, "Expected 1 update from subscription"); // Publish the unindexed version again, removing the index. // The initial subscription should be rejected again. From 7790028341cd50321c3fac9c71bacd62fd9629ec Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Tue, 27 Jan 2026 14:41:33 -0800 Subject: [PATCH 075/118] [tyler/translate-smoketests]: remove comments referencing deprecated files --- crates/smoketests/tests/smoketests/add_remove_index.rs | 2 -- crates/smoketests/tests/smoketests/auto_inc.rs | 2 -- crates/smoketests/tests/smoketests/auto_migration.rs | 2 -- crates/smoketests/tests/smoketests/call.rs | 2 -- crates/smoketests/tests/smoketests/client_connection_errors.rs | 2 -- crates/smoketests/tests/smoketests/confirmed_reads.rs | 2 -- .../smoketests/tests/smoketests/connect_disconnect_from_cli.rs | 2 -- crates/smoketests/tests/smoketests/create_project.rs | 2 -- crates/smoketests/tests/smoketests/csharp_module.rs | 2 -- crates/smoketests/tests/smoketests/default_module_clippy.rs | 2 -- crates/smoketests/tests/smoketests/delete_database.rs | 2 -- crates/smoketests/tests/smoketests/describe.rs | 2 -- crates/smoketests/tests/smoketests/detect_wasm_bindgen.rs | 2 -- crates/smoketests/tests/smoketests/dml.rs | 2 -- crates/smoketests/tests/smoketests/domains.rs | 2 -- crates/smoketests/tests/smoketests/energy.rs | 2 -- crates/smoketests/tests/smoketests/fail_initial_publish.rs | 2 -- crates/smoketests/tests/smoketests/filtering.rs | 2 -- crates/smoketests/tests/smoketests/module_nested_op.rs | 2 -- crates/smoketests/tests/smoketests/modules.rs | 2 -- crates/smoketests/tests/smoketests/namespaces.rs | 2 -- crates/smoketests/tests/smoketests/new_user_flow.rs | 2 -- crates/smoketests/tests/smoketests/panic.rs | 2 -- crates/smoketests/tests/smoketests/permissions.rs | 2 -- crates/smoketests/tests/smoketests/pg_wire.rs | 2 -- crates/smoketests/tests/smoketests/quickstart.rs | 2 -- crates/smoketests/tests/smoketests/rls.rs | 2 -- crates/smoketests/tests/smoketests/schedule_reducer.rs | 2 -- crates/smoketests/tests/smoketests/servers.rs | 2 -- crates/smoketests/tests/smoketests/sql.rs | 2 -- crates/smoketests/tests/smoketests/timestamp_route.rs | 2 -- crates/smoketests/tests/smoketests/views.rs | 2 -- 32 files changed, 64 deletions(-) diff --git a/crates/smoketests/tests/smoketests/add_remove_index.rs b/crates/smoketests/tests/smoketests/add_remove_index.rs index a77d101331a..d8ba3413165 100644 --- a/crates/smoketests/tests/smoketests/add_remove_index.rs +++ b/crates/smoketests/tests/smoketests/add_remove_index.rs @@ -1,5 +1,3 @@ -//! Add/remove index tests translated from smoketests/tests/add_remove_index.py - use spacetimedb_smoketests::Smoketest; const JOIN_QUERY: &str = "select t1.* from t1 join t2 on t1.id = t2.id where t2.id = 1001"; diff --git a/crates/smoketests/tests/smoketests/auto_inc.rs b/crates/smoketests/tests/smoketests/auto_inc.rs index 2cbfe14baa0..101694372ac 100644 --- a/crates/smoketests/tests/smoketests/auto_inc.rs +++ b/crates/smoketests/tests/smoketests/auto_inc.rs @@ -1,5 +1,3 @@ -//! Auto-increment tests translated from smoketests/tests/auto_inc.py - use spacetimedb_smoketests::Smoketest; const INT_TYPES: &[&str] = &["u8", "u16", "u32", "u64", "u128", "i8", "i16", "i32", "i64", "i128"]; diff --git a/crates/smoketests/tests/smoketests/auto_migration.rs b/crates/smoketests/tests/smoketests/auto_migration.rs index bac8b14e68e..d8b08a49b83 100644 --- a/crates/smoketests/tests/smoketests/auto_migration.rs +++ b/crates/smoketests/tests/smoketests/auto_migration.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/auto_migration.py - use spacetimedb_smoketests::Smoketest; const MODULE_CODE_SIMPLE: &str = r#" diff --git a/crates/smoketests/tests/smoketests/call.rs b/crates/smoketests/tests/smoketests/call.rs index 42729938547..9c033979bb7 100644 --- a/crates/smoketests/tests/smoketests/call.rs +++ b/crates/smoketests/tests/smoketests/call.rs @@ -1,5 +1,3 @@ -//! Reducer/procedure call tests translated from smoketests/tests/call.py - use spacetimedb_smoketests::Smoketest; /// Check calling a reducer (no return) and procedure (return) diff --git a/crates/smoketests/tests/smoketests/client_connection_errors.rs b/crates/smoketests/tests/smoketests/client_connection_errors.rs index edf72879ca4..dc6d435e0ff 100644 --- a/crates/smoketests/tests/smoketests/client_connection_errors.rs +++ b/crates/smoketests/tests/smoketests/client_connection_errors.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/client_connected_error_rejects_connection.py - use spacetimedb_smoketests::Smoketest; /// Test that client_connected returning an error rejects the connection diff --git a/crates/smoketests/tests/smoketests/confirmed_reads.rs b/crates/smoketests/tests/smoketests/confirmed_reads.rs index d30d1e5776c..709556e8fa5 100644 --- a/crates/smoketests/tests/smoketests/confirmed_reads.rs +++ b/crates/smoketests/tests/smoketests/confirmed_reads.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/confirmed_reads.py -//! //! TODO: We only test that we can pass a --confirmed flag and that things //! appear to work as if we hadn't. Without controlling the server, we can't //! test that there is any difference in behavior. diff --git a/crates/smoketests/tests/smoketests/connect_disconnect_from_cli.rs b/crates/smoketests/tests/smoketests/connect_disconnect_from_cli.rs index 58c9d5d6f55..c7259d63eb3 100644 --- a/crates/smoketests/tests/smoketests/connect_disconnect_from_cli.rs +++ b/crates/smoketests/tests/smoketests/connect_disconnect_from_cli.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/connect_disconnect_from_cli.py - use spacetimedb_smoketests::Smoketest; /// Ensure that the connect and disconnect functions are called when invoking a reducer from the CLI diff --git a/crates/smoketests/tests/smoketests/create_project.rs b/crates/smoketests/tests/smoketests/create_project.rs index 566778888d4..1c77559661e 100644 --- a/crates/smoketests/tests/smoketests/create_project.rs +++ b/crates/smoketests/tests/smoketests/create_project.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/create_project.py - use spacetimedb_guard::ensure_binaries_built; use std::process::Command; use tempfile::tempdir; diff --git a/crates/smoketests/tests/smoketests/csharp_module.rs b/crates/smoketests/tests/smoketests/csharp_module.rs index 7dc6c30159f..4f72f3e4c1b 100644 --- a/crates/smoketests/tests/smoketests/csharp_module.rs +++ b/crates/smoketests/tests/smoketests/csharp_module.rs @@ -1,6 +1,4 @@ #![allow(clippy::disallowed_macros)] -//! Tests translated from smoketests/tests/csharp_module.py - use spacetimedb_guard::ensure_binaries_built; use spacetimedb_smoketests::{have_dotnet, workspace_root}; use std::fs; diff --git a/crates/smoketests/tests/smoketests/default_module_clippy.rs b/crates/smoketests/tests/smoketests/default_module_clippy.rs index 96120806fcf..f32b0fd94e2 100644 --- a/crates/smoketests/tests/smoketests/default_module_clippy.rs +++ b/crates/smoketests/tests/smoketests/default_module_clippy.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/default_module_clippy.py -//! //! These tests verify that the Rust module templates have no clippy warnings. use std::path::PathBuf; diff --git a/crates/smoketests/tests/smoketests/delete_database.rs b/crates/smoketests/tests/smoketests/delete_database.rs index 86cd246ff92..c101304563f 100644 --- a/crates/smoketests/tests/smoketests/delete_database.rs +++ b/crates/smoketests/tests/smoketests/delete_database.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/delete_database.py - use spacetimedb_smoketests::Smoketest; use std::thread; use std::time::Duration; diff --git a/crates/smoketests/tests/smoketests/describe.rs b/crates/smoketests/tests/smoketests/describe.rs index 0778722b014..72d66c57008 100644 --- a/crates/smoketests/tests/smoketests/describe.rs +++ b/crates/smoketests/tests/smoketests/describe.rs @@ -1,5 +1,3 @@ -//! Module description tests translated from smoketests/tests/describe.py - use spacetimedb_smoketests::Smoketest; /// Check describing a module diff --git a/crates/smoketests/tests/smoketests/detect_wasm_bindgen.rs b/crates/smoketests/tests/smoketests/detect_wasm_bindgen.rs index 9b99ccb6280..8ff2224cdab 100644 --- a/crates/smoketests/tests/smoketests/detect_wasm_bindgen.rs +++ b/crates/smoketests/tests/smoketests/detect_wasm_bindgen.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/detect_wasm_bindgen.py - use spacetimedb_smoketests::Smoketest; /// Module code that uses wasm_bindgen (should be rejected) diff --git a/crates/smoketests/tests/smoketests/dml.rs b/crates/smoketests/tests/smoketests/dml.rs index 4d2e1799015..a8a5a78ba9a 100644 --- a/crates/smoketests/tests/smoketests/dml.rs +++ b/crates/smoketests/tests/smoketests/dml.rs @@ -1,5 +1,3 @@ -//! DML tests translated from smoketests/tests/dml.py - use spacetimedb_smoketests::Smoketest; /// Test that we receive subscription updates from DML diff --git a/crates/smoketests/tests/smoketests/domains.rs b/crates/smoketests/tests/smoketests/domains.rs index 6afdec37930..5acf85e848f 100644 --- a/crates/smoketests/tests/smoketests/domains.rs +++ b/crates/smoketests/tests/smoketests/domains.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/domains.py - use spacetimedb_smoketests::Smoketest; /// Tests the functionality of the rename command diff --git a/crates/smoketests/tests/smoketests/energy.rs b/crates/smoketests/tests/smoketests/energy.rs index 65e1eba5a1e..b92f0e47e25 100644 --- a/crates/smoketests/tests/smoketests/energy.rs +++ b/crates/smoketests/tests/smoketests/energy.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/energy.py - use regex::Regex; use spacetimedb_smoketests::Smoketest; diff --git a/crates/smoketests/tests/smoketests/fail_initial_publish.rs b/crates/smoketests/tests/smoketests/fail_initial_publish.rs index c56ad84c732..70ac7e1dd29 100644 --- a/crates/smoketests/tests/smoketests/fail_initial_publish.rs +++ b/crates/smoketests/tests/smoketests/fail_initial_publish.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/fail_initial_publish.py - use spacetimedb_smoketests::Smoketest; /// Module code with a bug: `Person` is the wrong table name, should be `person` diff --git a/crates/smoketests/tests/smoketests/filtering.rs b/crates/smoketests/tests/smoketests/filtering.rs index d1ba54d07e4..c1b9a8a85ea 100644 --- a/crates/smoketests/tests/smoketests/filtering.rs +++ b/crates/smoketests/tests/smoketests/filtering.rs @@ -1,5 +1,3 @@ -//! Filtering tests translated from smoketests/tests/filtering.py - use spacetimedb_smoketests::Smoketest; /// Test filtering reducers diff --git a/crates/smoketests/tests/smoketests/module_nested_op.rs b/crates/smoketests/tests/smoketests/module_nested_op.rs index 53a902b362c..7cdfdcb7042 100644 --- a/crates/smoketests/tests/smoketests/module_nested_op.rs +++ b/crates/smoketests/tests/smoketests/module_nested_op.rs @@ -1,5 +1,3 @@ -//! Nested table operation tests translated from smoketests/tests/module_nested_op.py - use spacetimedb_smoketests::Smoketest; /// This tests uploading a basic module and calling some functions and checking logs afterwards. diff --git a/crates/smoketests/tests/smoketests/modules.rs b/crates/smoketests/tests/smoketests/modules.rs index d82b62f3c18..c44ab09676b 100644 --- a/crates/smoketests/tests/smoketests/modules.rs +++ b/crates/smoketests/tests/smoketests/modules.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/modules.py - use spacetimedb_smoketests::Smoketest; /// Test publishing a module without the --delete-data option diff --git a/crates/smoketests/tests/smoketests/namespaces.rs b/crates/smoketests/tests/smoketests/namespaces.rs index 030a9c2cc37..d50770d439f 100644 --- a/crates/smoketests/tests/smoketests/namespaces.rs +++ b/crates/smoketests/tests/smoketests/namespaces.rs @@ -1,5 +1,3 @@ -//! Namespace tests translated from smoketests/tests/namespaces.py - use spacetimedb_smoketests::Smoketest; use std::fs; use std::path::{Path, PathBuf}; diff --git a/crates/smoketests/tests/smoketests/new_user_flow.rs b/crates/smoketests/tests/smoketests/new_user_flow.rs index 41f2a39a70d..4058232c044 100644 --- a/crates/smoketests/tests/smoketests/new_user_flow.rs +++ b/crates/smoketests/tests/smoketests/new_user_flow.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/new_user_flow.py - use spacetimedb_smoketests::Smoketest; /// Test the entirety of the new user flow. diff --git a/crates/smoketests/tests/smoketests/panic.rs b/crates/smoketests/tests/smoketests/panic.rs index a6fe5e44075..3af42e149f3 100644 --- a/crates/smoketests/tests/smoketests/panic.rs +++ b/crates/smoketests/tests/smoketests/panic.rs @@ -1,5 +1,3 @@ -//! Panic and error handling tests translated from smoketests/tests/panic.py - use spacetimedb_smoketests::Smoketest; /// Tests to check if a SpacetimeDB module can handle a panic without corrupting diff --git a/crates/smoketests/tests/smoketests/permissions.rs b/crates/smoketests/tests/smoketests/permissions.rs index 9f487118d21..175b18e19a3 100644 --- a/crates/smoketests/tests/smoketests/permissions.rs +++ b/crates/smoketests/tests/smoketests/permissions.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/permissions.py - use spacetimedb_smoketests::Smoketest; use std::path::PathBuf; diff --git a/crates/smoketests/tests/smoketests/pg_wire.rs b/crates/smoketests/tests/smoketests/pg_wire.rs index 67f7227ffdb..a2aad486a67 100644 --- a/crates/smoketests/tests/smoketests/pg_wire.rs +++ b/crates/smoketests/tests/smoketests/pg_wire.rs @@ -1,6 +1,4 @@ #![allow(clippy::disallowed_macros)] -//! Tests translated from smoketests/tests/pg_wire.py - use spacetimedb_smoketests::{have_psql, Smoketest}; /// Test SQL output formatting via psql diff --git a/crates/smoketests/tests/smoketests/quickstart.rs b/crates/smoketests/tests/smoketests/quickstart.rs index f7c871447ce..d4e6394adf7 100644 --- a/crates/smoketests/tests/smoketests/quickstart.rs +++ b/crates/smoketests/tests/smoketests/quickstart.rs @@ -1,6 +1,4 @@ #![allow(clippy::disallowed_macros)] -//! Tests translated from smoketests/tests/quickstart.py -//! //! This test validates that the quickstart documentation is correct by extracting //! code from markdown docs and running it. diff --git a/crates/smoketests/tests/smoketests/rls.rs b/crates/smoketests/tests/smoketests/rls.rs index 4df27d225dd..91059b3edb5 100644 --- a/crates/smoketests/tests/smoketests/rls.rs +++ b/crates/smoketests/tests/smoketests/rls.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/rls.py - use spacetimedb_smoketests::Smoketest; /// Tests for querying tables with RLS rules diff --git a/crates/smoketests/tests/smoketests/schedule_reducer.rs b/crates/smoketests/tests/smoketests/schedule_reducer.rs index 85a4f07a090..863321c228c 100644 --- a/crates/smoketests/tests/smoketests/schedule_reducer.rs +++ b/crates/smoketests/tests/smoketests/schedule_reducer.rs @@ -1,5 +1,3 @@ -//! Scheduled reducer tests translated from smoketests/tests/schedule_reducer.py - use spacetimedb_smoketests::Smoketest; use std::thread; use std::time::Duration; diff --git a/crates/smoketests/tests/smoketests/servers.rs b/crates/smoketests/tests/smoketests/servers.rs index 2c10b8bc471..90c2426e0a4 100644 --- a/crates/smoketests/tests/smoketests/servers.rs +++ b/crates/smoketests/tests/smoketests/servers.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/servers.py - use regex::Regex; use spacetimedb_smoketests::Smoketest; diff --git a/crates/smoketests/tests/smoketests/sql.rs b/crates/smoketests/tests/smoketests/sql.rs index 86aad3a6375..ca8b1318f31 100644 --- a/crates/smoketests/tests/smoketests/sql.rs +++ b/crates/smoketests/tests/smoketests/sql.rs @@ -1,5 +1,3 @@ -//! SQL format tests translated from smoketests/tests/sql.py - use spacetimedb_smoketests::Smoketest; /// This test is designed to test the format of the output of sql queries diff --git a/crates/smoketests/tests/smoketests/timestamp_route.rs b/crates/smoketests/tests/smoketests/timestamp_route.rs index a177d1e4444..f3cdf1f494b 100644 --- a/crates/smoketests/tests/smoketests/timestamp_route.rs +++ b/crates/smoketests/tests/smoketests/timestamp_route.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/timestamp_route.py - use spacetimedb_smoketests::{random_string, Smoketest}; const TIMESTAMP_TAG: &str = "__timestamp_micros_since_unix_epoch__"; diff --git a/crates/smoketests/tests/smoketests/views.rs b/crates/smoketests/tests/smoketests/views.rs index 31fc92412c6..bf91a91809b 100644 --- a/crates/smoketests/tests/smoketests/views.rs +++ b/crates/smoketests/tests/smoketests/views.rs @@ -1,5 +1,3 @@ -//! Tests translated from smoketests/tests/views.py - use spacetimedb_smoketests::Smoketest; /// Tests that views populate the st_view_* system tables From 1a4ebccf2abb5fcc48e073168356b1098bec8265 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:44:14 -0800 Subject: [PATCH 076/118] add todo from @jdetter Signed-off-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --- crates/smoketests/tests/smoketests/new_user_flow.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/smoketests/tests/smoketests/new_user_flow.rs b/crates/smoketests/tests/smoketests/new_user_flow.rs index 4058232c044..a153e44c3c2 100644 --- a/crates/smoketests/tests/smoketests/new_user_flow.rs +++ b/crates/smoketests/tests/smoketests/new_user_flow.rs @@ -1,5 +1,6 @@ use spacetimedb_smoketests::Smoketest; +// TODO: This test originally was testing to make sure that our tutorial isn't broken. Since our onboarding has changed we should probably update this test in the future. /// Test the entirety of the new user flow. #[test] fn test_new_user_flow() { From 6d59cb12a98c5a883f385ec1e653faccea0d0de8 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Wed, 28 Jan 2026 13:45:53 -0800 Subject: [PATCH 077/118] Update crates/guard/src/lib.rs Signed-off-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --- crates/guard/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index e64c5aa153c..a13871c5816 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -22,6 +22,7 @@ fn next_spawn_id() -> u64 { } /// Returns the workspace root directory. +// TODO: Should this use something like `git rev-parse --show-toplevel` to avoid being directory-relative? fn workspace_root() -> PathBuf { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); manifest_dir From 46e63e439cac0cebbf489fe9d172ab98a9349cfe Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Wed, 28 Jan 2026 13:48:42 -0800 Subject: [PATCH 078/118] Update crates/guard/src/lib.rs Signed-off-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --- crates/guard/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index a13871c5816..15bf6f1b57f 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -22,7 +22,7 @@ fn next_spawn_id() -> u64 { } /// Returns the workspace root directory. -// TODO: Should this use something like `git rev-parse --show-toplevel` to avoid being directory-relative? +// TODO: Should this use something like `git rev-parse --show-toplevel` to avoid being directory-relative? Or perhaps `CARGO_WORKSPACE_DIR` is set? fn workspace_root() -> PathBuf { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); manifest_dir From 6a47135d905c379ef8c18826a59263334d4ed738 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Wed, 28 Jan 2026 14:03:53 -0800 Subject: [PATCH 079/118] [tyler/translate-smoketests]: unused --- crates/guard/src/lib.rs | 32 +++++--------------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 15bf6f1b57f..383a3f84ceb 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -121,15 +121,8 @@ impl SpacetimeDbGuard { pub fn spawn_in_temp_data_dir_with_pg_port(pg_port: Option) -> Self { let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); let data_dir_path = temp_dir.path().to_path_buf(); - let data_dir_str = data_dir_path.display().to_string(); - - Self::spawn_spacetime_start_with_data_dir( - false, - &["start", "--data-dir", &data_dir_str], - pg_port, - data_dir_path, - Some(temp_dir), - ) + + Self::spawn_spacetime_start_with_data_dir(false, pg_port, data_dir_path, Some(temp_dir)) } /// Start `spacetimedb` in a temporary data directory via: @@ -137,15 +130,8 @@ impl SpacetimeDbGuard { pub fn spawn_in_temp_data_dir_use_cli() -> Self { let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); let data_dir_path = temp_dir.path().to_path_buf(); - let data_dir_str = data_dir_path.display().to_string(); - - Self::spawn_spacetime_start_with_data_dir( - true, - &["start", "--data-dir", &data_dir_str], - None, - data_dir_path, - Some(temp_dir), - ) + + Self::spawn_spacetime_start_with_data_dir(true, None, data_dir_path, Some(temp_dir)) } /// Start `spacetimedb` with an explicit data directory (for restart scenarios). @@ -153,19 +139,11 @@ impl SpacetimeDbGuard { /// Unlike `spawn_in_temp_data_dir`, this method does not create a temporary directory. /// The caller is responsible for managing the data directory lifetime. pub fn spawn_with_data_dir(data_dir: PathBuf, pg_port: Option) -> Self { - let data_dir_str = data_dir.display().to_string(); - Self::spawn_spacetime_start_with_data_dir( - false, - &["start", "--data-dir", &data_dir_str], - pg_port, - data_dir, - None, - ) + Self::spawn_spacetime_start_with_data_dir(false, pg_port, data_dir, None) } fn spawn_spacetime_start_with_data_dir( use_installed_cli: bool, - _extra_args: &[&str], pg_port: Option, data_dir: PathBuf, _data_dir_handle: Option, From 89a76f8664e4f751029f9886da2fb383b44d1567 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Wed, 28 Jan 2026 15:42:26 -0800 Subject: [PATCH 080/118] [tyler/translate-smoketests]: properly use pg_port --- crates/guard/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 383a3f84ceb..b6eab6a4414 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -157,7 +157,10 @@ impl SpacetimeDbGuard { let address = "127.0.0.1:0".to_string(); let data_dir_str = data_dir.display().to_string(); - let args = ["start", "--data-dir", &data_dir_str, "--listen-addr", &address]; + let args = vec!["start", "--data-dir", &data_dir_str, "--listen-addr", &address]; + if let Some(ref port) = pg_port_str { + args.extend(["--pg-port", port]); + } let cmd = Command::new("spacetime"); let (child, logs, reader_threads) = Self::spawn_child(cmd, env!("CARGO_MANIFEST_DIR"), &args, spawn_id); From 4ee0e9ce09b276a5c37de22c242535488479574d Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Thu, 29 Jan 2026 10:21:51 -0800 Subject: [PATCH 081/118] [tyler/translate-smoketests]: debug changes --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e566a971c52..6294e9e05b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,9 +19,9 @@ concurrency: jobs: smoketests: - needs: [lints] name: Smoketests strategy: + fail-fast: false matrix: runner: [spacetimedb-new-runner, windows-latest] include: From 92a404832ed572fd496ff53b27e74461f3923efd Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Thu, 29 Jan 2026 10:25:38 -0800 Subject: [PATCH 082/118] [tyler/translate-smoketests]: fix build --- crates/guard/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index b6eab6a4414..3300baa8f4a 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -156,8 +156,9 @@ impl SpacetimeDbGuard { let address = "127.0.0.1:0".to_string(); let data_dir_str = data_dir.display().to_string(); + let pg_port_str = pg_port.map(|p| p.to_string()); - let args = vec!["start", "--data-dir", &data_dir_str, "--listen-addr", &address]; + let mut args = vec!["start", "--data-dir", &data_dir_str, "--listen-addr", &address]; if let Some(ref port) = pg_port_str { args.extend(["--pg-port", port]); } From 97794ae971fda06189146b61f91ea280931c04cb Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Thu, 29 Jan 2026 13:02:49 -0800 Subject: [PATCH 083/118] [tyler/translate-smoketests]: fix timestamp_route test --- crates/smoketests/tests/smoketests/timestamp_route.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/smoketests/tests/smoketests/timestamp_route.rs b/crates/smoketests/tests/smoketests/timestamp_route.rs index f3cdf1f494b..3130b9c5746 100644 --- a/crates/smoketests/tests/smoketests/timestamp_route.rs +++ b/crates/smoketests/tests/smoketests/timestamp_route.rs @@ -9,6 +9,9 @@ fn test_timestamp_route() { let name = random_string(); + // Since we didn't publish, we're not logged in yet, so `api_call` will fail to get a token. + test.new_identity().unwrap(); + // A request for the timestamp at a non-existent database is an error with code 404 let resp = test .api_call("GET", &format!("/v1/database/{}/unstable/timestamp", name)) From 36a4e6c7b0af1731132a38f0585c4ffaf47ace6d Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Thu, 29 Jan 2026 14:49:25 -0800 Subject: [PATCH 084/118] [tyler/translate-smoketests]: simplify --- crates/guard/src/lib.rs | 75 ++++++++--------------------------------- 1 file changed, 14 insertions(+), 61 deletions(-) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 3300baa8f4a..98661d60802 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -154,37 +154,9 @@ impl SpacetimeDbGuard { // Use the installed CLI (rare case, mainly for spawn_in_temp_data_dir_use_cli) eprintln!("[SPAWN-{:03}] START (installed CLI) data_dir={:?}", spawn_id, data_dir); - let address = "127.0.0.1:0".to_string(); - let data_dir_str = data_dir.display().to_string(); - let pg_port_str = pg_port.map(|p| p.to_string()); - - let mut args = vec!["start", "--data-dir", &data_dir_str, "--listen-addr", &address]; - if let Some(ref port) = pg_port_str { - args.extend(["--pg-port", port]); - } let cmd = Command::new("spacetime"); - let (child, logs, reader_threads) = Self::spawn_child(cmd, env!("CARGO_MANIFEST_DIR"), &args, spawn_id); - - eprintln!("[SPAWN-{:03}] Waiting for listen address", spawn_id); - let listen_addr = wait_for_listen_addr(&logs, Duration::from_secs(10), spawn_id).unwrap_or_else(|| { - let buf = logs.lock().unwrap(); - eprintln!("[SPAWN-{:03}] TIMEOUT after 10s", spawn_id); - eprintln!( - "[SPAWN-{:03}] Captured {} bytes, {} lines", - spawn_id, - buf.len(), - buf.lines().count() - ); - eprintln!( - "[SPAWN-{:03}] Contains 'Starting SpacetimeDB': {}", - spawn_id, - buf.contains("Starting SpacetimeDB") - ); - panic!("Timed out waiting for SpacetimeDB to report listen address") - }); - eprintln!("[SPAWN-{:03}] Got listen_addr={}", spawn_id, listen_addr); - - let host_url = format!("http://{}", listen_addr); + let (child, logs, host_url, reader_threads) = + Self::spawn_server_with_command(&data_dir, pg_port, spawn_id, cmd); let guard = SpacetimeDbGuard { child, host_url, @@ -194,8 +166,6 @@ impl SpacetimeDbGuard { _data_dir_handle, reader_threads, }; - guard.wait_until_http_ready(Duration::from_secs(10)); - eprintln!("[SPAWN-{:03}] HTTP ready", spawn_id); guard } else { // Use the built CLI (common case) @@ -298,26 +268,29 @@ impl SpacetimeDbGuard { spawn_id, data_dir, pg_port ); + let cli_path = ensure_binaries_built(); + let cmd = Command::new(&cli_path); + Self::spawn_server_with_command(data_dir, pg_port, spawn_id, cmd) + } + + fn spawn_server_with_command( + data_dir: &Path, + pg_port: Option, + spawn_id: u64, + cmd: Command, + ) -> (Child, Arc>, String, Vec>) { let data_dir_str = data_dir.display().to_string(); let pg_port_str = pg_port.map(|p| p.to_string()); let address = "127.0.0.1:0".to_string(); - let cli_path = ensure_binaries_built(); let mut args = vec!["start", "--data-dir", &data_dir_str, "--listen-addr", &address]; if let Some(ref port) = pg_port_str { args.extend(["--pg-port", port]); } - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_root = manifest_dir - .parent() - .and_then(|p| p.parent()) - .expect("Failed to find workspace root"); - eprintln!("[SPAWN-{:03}] Spawning child process", spawn_id); - let cmd = Command::new(&cli_path); - let (child, logs, reader_threads) = Self::spawn_child(cmd, workspace_root.to_str().unwrap(), &args, spawn_id); + let (child, logs, reader_threads) = Self::spawn_child(cmd, &args, spawn_id); eprintln!("[SPAWN-{:03}] Child spawned pid={}", spawn_id, child.id()); // Wait for the server to be ready @@ -364,7 +337,6 @@ impl SpacetimeDbGuard { fn spawn_child( mut cmd: Command, - workspace_dir: &str, args: &[&str], spawn_id: u64, ) -> (Child, Arc>, Vec>) { @@ -372,7 +344,6 @@ impl SpacetimeDbGuard { let mut child = cmd .args(args) - .current_dir(workspace_dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() @@ -439,24 +410,6 @@ impl SpacetimeDbGuard { eprintln!("[SPAWN-{:03}] spawn_child: readers attached", spawn_id); (child, logs, reader_threads) } - - fn wait_until_http_ready(&self, timeout: Duration) { - let client = Client::new(); - let deadline = Instant::now() + timeout; - - while Instant::now() < deadline { - let url = format!("{}/v1/ping", self.host_url); - - if let Ok(resp) = client.get(&url).send() { - if resp.status().is_success() { - return; // Fully ready! - } - } - - sleep(Duration::from_millis(50)); - } - panic!("Timed out waiting for SpacetimeDB HTTP /v1/ping at {}", self.host_url); - } } /// Wait for a line like: From b69dca71d1d40cdc9826a22b7f4f37bec7da9097 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Thu, 29 Jan 2026 15:10:22 -0800 Subject: [PATCH 085/118] [tyler/translate-smoketests]: review --- crates/guard/src/lib.rs | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 98661d60802..80f05c138eb 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -169,7 +169,10 @@ impl SpacetimeDbGuard { guard } else { // Use the built CLI (common case) - let (child, logs, host_url, reader_threads) = Self::spawn_server(&data_dir, pg_port, spawn_id); + let cli_path = ensure_binaries_built(); + let cmd = Command::new(&cli_path); + let (child, logs, host_url, reader_threads) = + Self::spawn_server_with_command(data_dir, pg_port, spawn_id, cmd); SpacetimeDbGuard { child, host_url, @@ -206,7 +209,11 @@ impl SpacetimeDbGuard { sleep(Duration::from_millis(100)); eprintln!("[RESTART-{:03}] Spawning new server", spawn_id); - let (child, logs, host_url, reader_threads) = Self::spawn_server(&self.data_dir, self.pg_port, spawn_id); + // Use the built CLI (common case) + let cli_path = ensure_binaries_built(); + let cmd = Command::new(&cli_path); + let (child, logs, host_url, reader_threads) = + Self::spawn_server_with_command(self.data_dir.clone(), self.pg_port, spawn_id, cmd); eprintln!( "[RESTART-{:03}] New server ready, pid={}, url={}", spawn_id, @@ -258,27 +265,17 @@ impl SpacetimeDbGuard { /// Spawns a new server process with the given data directory. /// Returns (child, logs, host_url, reader_threads). - fn spawn_server( + fn spawn_server_with_command( data_dir: &Path, pg_port: Option, spawn_id: u64, + cmd: Command, ) -> (Child, Arc>, String, Vec>) { eprintln!( "[SPAWN-{:03}] START data_dir={:?}, pg_port={:?}", spawn_id, data_dir, pg_port ); - let cli_path = ensure_binaries_built(); - let cmd = Command::new(&cli_path); - Self::spawn_server_with_command(data_dir, pg_port, spawn_id, cmd) - } - - fn spawn_server_with_command( - data_dir: &Path, - pg_port: Option, - spawn_id: u64, - cmd: Command, - ) -> (Child, Arc>, String, Vec>) { let data_dir_str = data_dir.display().to_string(); let pg_port_str = pg_port.map(|p| p.to_string()); From 4092597c0fb2afd275a4292a4c0b017ccc65350b Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 09:20:00 -0800 Subject: [PATCH 086/118] [tyler/translate-smoketests]: fix build --- crates/guard/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 80f05c138eb..0cdb515a021 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -172,7 +172,7 @@ impl SpacetimeDbGuard { let cli_path = ensure_binaries_built(); let cmd = Command::new(&cli_path); let (child, logs, host_url, reader_threads) = - Self::spawn_server_with_command(data_dir, pg_port, spawn_id, cmd); + Self::spawn_server_with_command(&data_dir, pg_port, spawn_id, cmd); SpacetimeDbGuard { child, host_url, @@ -213,7 +213,7 @@ impl SpacetimeDbGuard { let cli_path = ensure_binaries_built(); let cmd = Command::new(&cli_path); let (child, logs, host_url, reader_threads) = - Self::spawn_server_with_command(self.data_dir.clone(), self.pg_port, spawn_id, cmd); + Self::spawn_server_with_command(&self.data_dir, self.pg_port, spawn_id, cmd); eprintln!( "[RESTART-{:03}] New server ready, pid={}, url={}", spawn_id, From 85581086ccc20dad5ce359861e8b05f4888e5e9a Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 09:20:04 -0800 Subject: [PATCH 087/118] [tyler/translate-smoketests]: review --- tools/xtask-smoketest/src/main.rs | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/tools/xtask-smoketest/src/main.rs b/tools/xtask-smoketest/src/main.rs index 86e8030b2f6..4799edafa03 100644 --- a/tools/xtask-smoketest/src/main.rs +++ b/tools/xtask-smoketest/src/main.rs @@ -144,13 +144,15 @@ fn run_smoketest(server: Option, args: Vec) -> Result<()> { .map(|s| s.success()) .unwrap_or(false); + // Set remote server environment variable if specified + if let Some(ref server_url) = server { + cmd.env("SPACETIME_REMOTE_SERVER", server_url); + eprintln!("Running smoketests against remote server {server_url}...\n"); + } + // 5. Run tests with appropriate runner (release mode for faster execution) let status = if use_nextest { - if server.is_some() { - eprintln!("Running smoketests against remote server with cargo nextest (release)...\n"); - } else { - eprintln!("Running smoketests with cargo nextest (release)...\n"); - } + eprintln!("Running smoketests with cargo nextest...\n"); let mut cmd = Command::new("cargo"); cmd.args([ "nextest", @@ -169,26 +171,12 @@ fn run_smoketest(server: Option, args: Vec) -> Result<()> { cmd.args(["-j", DEFAULT_PARALLELISM]); } - // Set remote server environment variable if specified - if let Some(ref server_url) = server { - cmd.env("SPACETIME_REMOTE_SERVER", server_url); - } - cmd.args(&args).status()? } else { - if server.is_some() { - eprintln!("Running smoketests against remote server with cargo test (release)...\n"); - } else { - eprintln!("Running smoketests with cargo test (release)...\n"); - } + eprintln!("Running smoketests with cargo test...\n"); let mut cmd = Command::new("cargo"); cmd.args(["test", "--release", "-p", "spacetimedb-smoketests"]); - // Set remote server environment variable if specified - if let Some(ref server_url) = server { - cmd.env("SPACETIME_REMOTE_SERVER", server_url); - } - cmd.args(&args).status()? }; From ea0768772176f18c4f717f05439d477377f558ad Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 09:22:44 -0800 Subject: [PATCH 088/118] [tyler/translate-smoketests]: review --- tools/xtask-smoketest/src/main.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/xtask-smoketest/src/main.rs b/tools/xtask-smoketest/src/main.rs index 4799edafa03..8a3473ad974 100644 --- a/tools/xtask-smoketest/src/main.rs +++ b/tools/xtask-smoketest/src/main.rs @@ -84,6 +84,9 @@ fn build_binaries() -> Result<()> { for (key, _) in env::vars() { let should_remove = (key.starts_with("CARGO") && key != "CARGO_HOME" && key != "CARGO_TARGET_DIR") || key.starts_with("RUST") + // > The environment variable `__CARGO_FIX_YOLO` is an undocumented, internal-use-only feature + // > for the Rust cargo fix command (and cargo clippy --fix) that forces the application of all + // > available suggestions, including those that are marked as potentially incorrect or dangerous. || key == "__CARGO_FIX_YOLO"; if should_remove { cmd.env_remove(&key); From 5e2fc8811b3f304f520226d653249ab68a58f2b0 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 09:41:31 -0800 Subject: [PATCH 089/118] [tyler/translate-smoketests]: review and fix build --- crates/guard/src/lib.rs | 67 ++++++++++++------------------- tools/xtask-smoketest/src/main.rs | 10 ++++- 2 files changed, 34 insertions(+), 43 deletions(-) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 0cdb515a021..49e92910f3b 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -105,6 +105,7 @@ pub struct SpacetimeDbGuard { _data_dir_handle: Option, /// Reader thread handles for stdout/stderr - joined on drop to prevent leaks. reader_threads: Vec>, + use_installed_cli: bool, } // Remove all Cargo-provided env vars from a child process. These are set by the fact that we're running in a cargo @@ -149,40 +150,19 @@ impl SpacetimeDbGuard { _data_dir_handle: Option, ) -> Self { let spawn_id = next_spawn_id(); - - if use_installed_cli { - // Use the installed CLI (rare case, mainly for spawn_in_temp_data_dir_use_cli) - eprintln!("[SPAWN-{:03}] START (installed CLI) data_dir={:?}", spawn_id, data_dir); - - let cmd = Command::new("spacetime"); - let (child, logs, host_url, reader_threads) = - Self::spawn_server_with_command(&data_dir, pg_port, spawn_id, cmd); - let guard = SpacetimeDbGuard { - child, - host_url, - logs, - pg_port, - data_dir, - _data_dir_handle, - reader_threads, - }; - guard - } else { - // Use the built CLI (common case) - let cli_path = ensure_binaries_built(); - let cmd = Command::new(&cli_path); - let (child, logs, host_url, reader_threads) = - Self::spawn_server_with_command(&data_dir, pg_port, spawn_id, cmd); - SpacetimeDbGuard { - child, - host_url, - logs, - pg_port, - data_dir, - _data_dir_handle, - reader_threads, - } - } + let (child, logs, host_url, reader_threads) = + Self::spawn_server(&data_dir, pg_port, spawn_id, use_installed_cli); + let guard = SpacetimeDbGuard { + child, + host_url, + logs, + pg_port, + data_dir, + _data_dir_handle, + reader_threads, + use_installed_cli, + }; + guard } /// Stop the server process without dropping the guard. @@ -209,11 +189,8 @@ impl SpacetimeDbGuard { sleep(Duration::from_millis(100)); eprintln!("[RESTART-{:03}] Spawning new server", spawn_id); - // Use the built CLI (common case) - let cli_path = ensure_binaries_built(); - let cmd = Command::new(&cli_path); let (child, logs, host_url, reader_threads) = - Self::spawn_server_with_command(&self.data_dir, self.pg_port, spawn_id, cmd); + Self::spawn_server(&self.data_dir, self.pg_port, spawn_id, self.use_installed_cli); eprintln!( "[RESTART-{:03}] New server ready, pid={}, url={}", spawn_id, @@ -265,14 +242,22 @@ impl SpacetimeDbGuard { /// Spawns a new server process with the given data directory. /// Returns (child, logs, host_url, reader_threads). - fn spawn_server_with_command( + fn spawn_server( data_dir: &Path, pg_port: Option, spawn_id: u64, - cmd: Command, + use_installed_cli: bool, ) -> (Child, Arc>, String, Vec>) { + let cmd = if use_installed_cli { + eprintln!("[SPAWN-{:03}] START Using installed CLI", spawn_id); + Command::new("spacetime") + } else { + // Use the built CLI (common case) + let cli_path = ensure_binaries_built(); + Command::new(&cli_path) + }; eprintln!( - "[SPAWN-{:03}] START data_dir={:?}, pg_port={:?}", + "[SPAWN-{:03}] START data_dir={:?} pg_port={:?}", spawn_id, data_dir, pg_port ); diff --git a/tools/xtask-smoketest/src/main.rs b/tools/xtask-smoketest/src/main.rs index 8a3473ad974..6774246d84c 100644 --- a/tools/xtask-smoketest/src/main.rs +++ b/tools/xtask-smoketest/src/main.rs @@ -147,9 +147,7 @@ fn run_smoketest(server: Option, args: Vec) -> Result<()> { .map(|s| s.success()) .unwrap_or(false); - // Set remote server environment variable if specified if let Some(ref server_url) = server { - cmd.env("SPACETIME_REMOTE_SERVER", server_url); eprintln!("Running smoketests against remote server {server_url}...\n"); } @@ -174,12 +172,20 @@ fn run_smoketest(server: Option, args: Vec) -> Result<()> { cmd.args(["-j", DEFAULT_PARALLELISM]); } + if let Some(ref server_url) = server { + cmd.env("SPACETIME_REMOTE_SERVER", server_url); + } + cmd.args(&args).status()? } else { eprintln!("Running smoketests with cargo test...\n"); let mut cmd = Command::new("cargo"); cmd.args(["test", "--release", "-p", "spacetimedb-smoketests"]); + if let Some(ref server_url) = server { + cmd.env("SPACETIME_REMOTE_SERVER", server_url); + } + cmd.args(&args).status()? }; From dc605ee9ede6881441595faf7f15777acf44045d Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 09:55:24 -0800 Subject: [PATCH 090/118] [tyler/translate-smoketests]: review/fix --- crates/smoketests/src/modules.rs | 43 ++++++++++++++++---------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/crates/smoketests/src/modules.rs b/crates/smoketests/src/modules.rs index 761893ad1be..9a6ced1a8fc 100644 --- a/crates/smoketests/src/modules.rs +++ b/crates/smoketests/src/modules.rs @@ -80,42 +80,41 @@ fn build_registry() -> HashMap { for entry in entries.filter_map(Result::ok) { let path = entry.path(); - let Some(filename) = path.file_name().and_then(|n| n.to_str()) else { - continue; - }; - - // Only process smoketest_module_*.wasm files - if !filename.starts_with("smoketest_module_") || !filename.ends_with(".wasm") { - continue; + if let Some(module_name) = wasm_to_module_name(path.clone()) { + reg.insert(module_name, path); } + } + + reg +} - // Extract module name: smoketest_module_foo_bar.wasm -> foo-bar - let module_name = filename +/// Extract module name: smoketest_module_foo_bar.wasm -> foo-bar +fn wasm_to_module_name(path: PathBuf) -> Option { + let filename = path.file_name()?.to_str()?; + // Only process smoketest_module_*.wasm files + if !filename.starts_with("smoketest_module_") || !filename.ends_with(".wasm") { + return None; + } + Some( + filename .strip_prefix("smoketest_module_") .unwrap() .strip_suffix(".wasm") .unwrap() - .replace('_', "-"); - - reg.insert(module_name, path); - } - - reg + .replace('_', "-"), + ) } #[cfg(test)] mod tests { + use super::*; + #[test] fn test_module_name_derivation() { // Test the naming convention let filename = "smoketest_module_foo_bar.wasm"; let expected = "foo-bar"; - let actual = filename - .strip_prefix("smoketest_module_") - .unwrap() - .strip_suffix(".wasm") - .unwrap() - .replace('_', "-"); - assert_eq!(actual, expected); + let actual = wasm_to_module_name(PathBuf::from(filename)); + assert_eq!(actual, Some(expected.to_string())); } } From bf05d896fec4d8c90f772f2545b329386d1d13f3 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 09:57:45 -0800 Subject: [PATCH 091/118] [tyler/translate-smoketests]: review --- crates/smoketests/src/modules.rs | 21 --------- .../smoketests/tests/smoketests/namespaces.rs | 46 +++++++++---------- 2 files changed, 22 insertions(+), 45 deletions(-) diff --git a/crates/smoketests/src/modules.rs b/crates/smoketests/src/modules.rs index 9a6ced1a8fc..83858819f5b 100644 --- a/crates/smoketests/src/modules.rs +++ b/crates/smoketests/src/modules.rs @@ -36,27 +36,6 @@ pub fn precompiled_module(name: &str) -> PathBuf { }) } -/// Returns true if pre-compiled modules are available. -/// -/// This checks if the modules workspace target directory exists and contains -/// at least one WASM file. -pub fn precompiled_modules_available() -> bool { - let target = modules_target_dir(); - if !target.exists() { - return false; - } - // Check if there's at least one smoketest_module_*.wasm file - std::fs::read_dir(&target) - .map(|entries| { - entries.filter_map(Result::ok).any(|e| { - e.file_name() - .to_str() - .is_some_and(|n| n.starts_with("smoketest_module_") && n.ends_with(".wasm")) - }) - }) - .unwrap_or(false) -} - /// Returns the target directory where pre-compiled WASM modules are stored. fn modules_target_dir() -> PathBuf { // Respect CARGO_TARGET_DIR if set (e.g., in CI), otherwise use the modules workspace's target dir diff --git a/crates/smoketests/tests/smoketests/namespaces.rs b/crates/smoketests/tests/smoketests/namespaces.rs index d50770d439f..b9582749e73 100644 --- a/crates/smoketests/tests/smoketests/namespaces.rs +++ b/crates/smoketests/tests/smoketests/namespaces.rs @@ -32,7 +32,7 @@ fn count_matches(dir: &Path, needle: &str) -> usize { /// Ensure that the default namespace is working properly #[test] fn test_spacetimedb_ns_csharp() { - let _test = Smoketest::builder() + let test = Smoketest::builder() .precompiled_module("namespaces") .autopublish(false) .build(); @@ -40,16 +40,15 @@ fn test_spacetimedb_ns_csharp() { let tmpdir = tempfile::tempdir().expect("Failed to create temp dir"); let project_path = workspace_root().join("crates/smoketests/modules/namespaces"); - _test - .spacetime(&[ - "generate", - "--out-dir", - tmpdir.path().to_str().unwrap(), - "--lang=csharp", - "--project-path", - project_path.to_str().unwrap(), - ]) - .unwrap(); + test.spacetime(&[ + "generate", + "--out-dir", + tmpdir.path().to_str().unwrap(), + "--lang=csharp", + "--project-path", + project_path.to_str().unwrap(), + ]) + .unwrap(); let namespace = "SpacetimeDB.Types"; assert_eq!( @@ -68,7 +67,7 @@ fn test_spacetimedb_ns_csharp() { /// Ensure that when a custom namespace is specified on the command line, it actually gets used in generation #[test] fn test_custom_ns_csharp() { - let _test = Smoketest::builder() + let test = Smoketest::builder() .precompiled_module("namespaces") .autopublish(false) .build(); @@ -79,18 +78,17 @@ fn test_custom_ns_csharp() { // Use a unique namespace name let namespace = "CustomTestNamespace"; - _test - .spacetime(&[ - "generate", - "--out-dir", - tmpdir.path().to_str().unwrap(), - "--lang=csharp", - "--namespace", - namespace, - "--project-path", - project_path.to_str().unwrap(), - ]) - .unwrap(); + test.spacetime(&[ + "generate", + "--out-dir", + tmpdir.path().to_str().unwrap(), + "--lang=csharp", + "--namespace", + namespace, + "--project-path", + project_path.to_str().unwrap(), + ]) + .unwrap(); assert_eq!( count_matches(tmpdir.path(), &format!("namespace {}", namespace)), From 29b125f29eb9b4aede86b5ee2e536667610ca344 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 10:00:05 -0800 Subject: [PATCH 092/118] [tyler/translate-smoketests]: review --- crates/smoketests/tests/smoketests/views.rs | 34 ++------------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/crates/smoketests/tests/smoketests/views.rs b/crates/smoketests/tests/smoketests/views.rs index bf91a91809b..776411decc4 100644 --- a/crates/smoketests/tests/smoketests/views.rs +++ b/crates/smoketests/tests/smoketests/views.rs @@ -21,41 +21,11 @@ fn test_st_view_tables() { ); } -const MODULE_CODE_BROKEN_NAMESPACE: &str = r#" -use spacetimedb::ViewContext; - -#[spacetimedb::table(name = person, public)] -pub struct Person { - name: String, -} - -#[spacetimedb::view(name = person, public)] -pub fn person(ctx: &ViewContext) -> Option { - None -} -"#; - -const MODULE_CODE_BROKEN_RETURN_TYPE: &str = r#" -use spacetimedb::{SpacetimeType, ViewContext}; - -#[derive(SpacetimeType)] -pub enum ABC { - A, - B, - C, -} - -#[spacetimedb::view(name = person, public)] -pub fn person(ctx: &ViewContext) -> Option { - None -} -"#; - /// Publishing a module should fail if a table and view have the same name #[test] fn test_fail_publish_namespace_collision() { let mut test = Smoketest::builder() - .module_code(MODULE_CODE_BROKEN_NAMESPACE) + .use_precompiled_module("views-broken-namespace") .autopublish(false) .build(); @@ -70,7 +40,7 @@ fn test_fail_publish_namespace_collision() { #[test] fn test_fail_publish_wrong_return_type() { let mut test = Smoketest::builder() - .module_code(MODULE_CODE_BROKEN_RETURN_TYPE) + .use_precompiled_module("views-broken-return-type") .autopublish(false) .build(); From 3bf8843090531e89c35daa2e49a56ec4467fddcc Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 10:21:26 -0800 Subject: [PATCH 093/118] [tyler/translate-smoketests]: fix --- crates/smoketests/tests/smoketests/views.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/smoketests/tests/smoketests/views.rs b/crates/smoketests/tests/smoketests/views.rs index 776411decc4..38510d59e9f 100644 --- a/crates/smoketests/tests/smoketests/views.rs +++ b/crates/smoketests/tests/smoketests/views.rs @@ -25,7 +25,7 @@ fn test_st_view_tables() { #[test] fn test_fail_publish_namespace_collision() { let mut test = Smoketest::builder() - .use_precompiled_module("views-broken-namespace") + .precompiled_module("views-broken-namespace") .autopublish(false) .build(); @@ -40,7 +40,7 @@ fn test_fail_publish_namespace_collision() { #[test] fn test_fail_publish_wrong_return_type() { let mut test = Smoketest::builder() - .use_precompiled_module("views-broken-return-type") + .precompiled_module("views-broken-return-type") .autopublish(false) .build(); From f69601f52149e5fb557828b0ab471a0383974e9d Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 10:21:36 -0800 Subject: [PATCH 094/118] [tyler/translate-smoketests]: fix --jwt-key-dir --- crates/guard/src/lib.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 49e92910f3b..27b61814c7e 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -266,7 +266,15 @@ impl SpacetimeDbGuard { let address = "127.0.0.1:0".to_string(); - let mut args = vec!["start", "--data-dir", &data_dir_str, "--listen-addr", &address]; + let mut args = vec![ + "start", + "--jwt-key-dir", + &data_dir_str, + "--data-dir", + &data_dir_str, + "--listen-addr", + &address, + ]; if let Some(ref port) = pg_port_str { args.extend(["--pg-port", port]); } From e37fc27ca8e5fa9da629cf9f05b3e348147ab589 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 10:52:13 -0800 Subject: [PATCH 095/118] [tyler/translate-smoketests]: add --dotnet false --- crates/smoketests/src/lib.rs | 27 +++++++++++++ .../tests/smoketests/csharp_module.rs | 7 +--- .../smoketests/tests/smoketests/quickstart.rs | 7 +--- tools/xtask-smoketest/src/main.rs | 38 ++++++++++--------- 4 files changed, 51 insertions(+), 28 deletions(-) diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index ebd9adeeadf..045286a57d2 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -104,6 +104,22 @@ macro_rules! skip_if_remote { }; } +#[macro_export] +macro_rules! requires_dotnet { + () => { + if !$crate::allow_dotnet() { + #[allow(clippy::disallowed_macros)] + { + eprintln!("Skipping dotnet test"); + } + return; + } + if !$crate::have_dotnet() { + panic!("Skipping dotnet test: dotnet not found"); + } + }; +} + /// Helper macro for timing operations and printing results macro_rules! timed { ($label:expr, $expr:expr) => {{ @@ -178,6 +194,17 @@ pub fn have_dotnet() -> bool { }) } +/// Returns true if tests are configured to allow dotnet +pub fn allow_dotnet() -> bool { + let Ok(s) = std::env::var("SMOKETESTS_DOTNET") else { + return true; + }; + match s.as_str() { + "" | "0" => false, + s => s.to_lowercase() != "false", + } +} + /// Returns true if psql (PostgreSQL client) is available on the system. pub fn have_psql() -> bool { static HAVE_PSQL: OnceLock = OnceLock::new(); diff --git a/crates/smoketests/tests/smoketests/csharp_module.rs b/crates/smoketests/tests/smoketests/csharp_module.rs index 4f72f3e4c1b..17698e5c4e9 100644 --- a/crates/smoketests/tests/smoketests/csharp_module.rs +++ b/crates/smoketests/tests/smoketests/csharp_module.rs @@ -1,6 +1,6 @@ #![allow(clippy::disallowed_macros)] use spacetimedb_guard::ensure_binaries_built; -use spacetimedb_smoketests::{have_dotnet, workspace_root}; +use spacetimedb_smoketests::{requires_dotnet, workspace_root}; use std::fs; use std::process::Command; @@ -9,10 +9,7 @@ use std::process::Command; /// Skips if dotnet 8.0+ is not available. #[test] fn test_build_csharp_module() { - if !have_dotnet() { - eprintln!("Skipping test_build_csharp_module: dotnet 8.0+ not available"); - return; - } + requires_dotnet!(); let workspace = workspace_root(); let bindings = workspace.join("crates/bindings-csharp"); diff --git a/crates/smoketests/tests/smoketests/quickstart.rs b/crates/smoketests/tests/smoketests/quickstart.rs index d4e6394adf7..1a0802e9f2d 100644 --- a/crates/smoketests/tests/smoketests/quickstart.rs +++ b/crates/smoketests/tests/smoketests/quickstart.rs @@ -3,7 +3,7 @@ //! code from markdown docs and running it. use anyhow::{bail, Context, Result}; -use spacetimedb_smoketests::{have_dotnet, have_pnpm, parse_quickstart, workspace_root, Smoketest}; +use spacetimedb_smoketests::{have_pnpm, parse_quickstart, requires_dotnet, workspace_root, Smoketest}; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -648,10 +648,7 @@ fn test_quickstart_rust() { /// Run the C# quickstart guides for server and client. #[test] fn test_quickstart_csharp() { - if !have_dotnet() { - eprintln!("Skipping test_quickstart_csharp: dotnet 8.0+ not available"); - return; - } + requires_dotnet!(); let mut qt = QuickstartTest::new(QuickstartConfig::csharp()); qt.run_quickstart().expect("C# quickstart test failed"); diff --git a/tools/xtask-smoketest/src/main.rs b/tools/xtask-smoketest/src/main.rs index 6774246d84c..b752af73bff 100644 --- a/tools/xtask-smoketest/src/main.rs +++ b/tools/xtask-smoketest/src/main.rs @@ -32,6 +32,9 @@ enum XtaskCmd { #[arg(long)] server: Option, + #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] + dotnet: bool, + /// Additional arguments to pass to the test runner #[arg(trailing_var_arg = true)] args: Vec, @@ -62,7 +65,8 @@ fn main() -> Result<()> { cmd: None, server, args, - } => run_smoketest(server, args), + dotnet: use_dotnet, + } => run_smoketest(server, use_dotnet, args), } } @@ -131,7 +135,7 @@ fn build_precompiled_modules() -> Result<()> { /// 16 was found to be optimal - higher values cause OS scheduler overhead. const DEFAULT_PARALLELISM: &str = "16"; -fn run_smoketest(server: Option, args: Vec) -> Result<()> { +fn run_smoketest(server: Option, dotnet: bool, args: Vec) -> Result<()> { // 1. Build binaries first (single process, no race) build_binaries()?; @@ -152,9 +156,10 @@ fn run_smoketest(server: Option, args: Vec) -> Result<()> { } // 5. Run tests with appropriate runner (release mode for faster execution) - let status = if use_nextest { + let mut cmd = if use_nextest { eprintln!("Running smoketests with cargo nextest...\n"); let mut cmd = Command::new("cargo"); + set_env(&mut cmd, server, dotnet); cmd.args([ "nextest", "run", @@ -165,30 +170,27 @@ fn run_smoketest(server: Option, args: Vec) -> Result<()> { ]); // Set default parallelism if user didn't specify -j - if !args - .iter() - .any(|a| a == "-j" || a.starts_with("-j") || a.starts_with("--jobs")) - { + if !args.iter().any(|a| a.starts_with("-j") || a.starts_with("--jobs")) { cmd.args(["-j", DEFAULT_PARALLELISM]); } - if let Some(ref server_url) = server { - cmd.env("SPACETIME_REMOTE_SERVER", server_url); - } - - cmd.args(&args).status()? + cmd } else { eprintln!("Running smoketests with cargo test...\n"); let mut cmd = Command::new("cargo"); + set_env(&mut cmd, server, dotnet); cmd.args(["test", "--release", "-p", "spacetimedb-smoketests"]); - - if let Some(ref server_url) = server { - cmd.env("SPACETIME_REMOTE_SERVER", server_url); - } - - cmd.args(&args).status()? + cmd }; + let status = cmd.arg("--").args(&args).status()?; ensure!(status.success(), "Tests failed"); Ok(()) } + +fn set_env(cmd: &mut Command, server: Option, dotnet: bool) { + if let Some(ref server_url) = server { + cmd.env("SPACETIME_REMOTE_SERVER", server_url); + } + cmd.env("SMOKETESTS_DOTNET", if dotnet { "1" } else { "0" }); +} From 94d89e9ded80134b4b09f211a6300e758897d672 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 10:57:53 -0800 Subject: [PATCH 096/118] [tyler/translate-smoketests]: fail loudly --- crates/smoketests/src/lib.rs | 20 ++++++++++++++++++- crates/smoketests/tests/smoketests/pg_wire.rs | 12 +++-------- .../smoketests/tests/smoketests/quickstart.rs | 7 ++----- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 045286a57d2..90bfae7a154 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -115,7 +115,25 @@ macro_rules! requires_dotnet { return; } if !$crate::have_dotnet() { - panic!("Skipping dotnet test: dotnet not found"); + panic!("dotnet 8.0+ not found"); + } + }; +} + +#[macro_export] +macro_rules! requires_psql { + () => { + if !$crate::have_psql() { + panic!("psql not found"); + } + }; +} + +#[macro_export] +macro_rules! requires_pnpm { + () => { + if !$crate::have_pnpm() { + panic!("pnpm not found"); } }; } diff --git a/crates/smoketests/tests/smoketests/pg_wire.rs b/crates/smoketests/tests/smoketests/pg_wire.rs index a2aad486a67..f66bb13b9c5 100644 --- a/crates/smoketests/tests/smoketests/pg_wire.rs +++ b/crates/smoketests/tests/smoketests/pg_wire.rs @@ -1,13 +1,10 @@ #![allow(clippy::disallowed_macros)] -use spacetimedb_smoketests::{have_psql, Smoketest}; +use spacetimedb_smoketests::{requires_psql, Smoketest}; /// Test SQL output formatting via psql #[test] fn test_sql_format() { - if !have_psql() { - eprintln!("Skipping test_sql_format: psql not available"); - return; - } + requires_psql!(); let mut test = Smoketest::builder() .precompiled_module("pg-wire") @@ -77,10 +74,7 @@ fn test_sql_format() { /// Test failure cases #[test] fn test_failures() { - if !have_psql() { - eprintln!("Skipping test_failures: psql not available"); - return; - } + requires_psql!(); let mut test = Smoketest::builder() .precompiled_module("pg-wire") diff --git a/crates/smoketests/tests/smoketests/quickstart.rs b/crates/smoketests/tests/smoketests/quickstart.rs index 1a0802e9f2d..a167c0885e6 100644 --- a/crates/smoketests/tests/smoketests/quickstart.rs +++ b/crates/smoketests/tests/smoketests/quickstart.rs @@ -3,7 +3,7 @@ //! code from markdown docs and running it. use anyhow::{bail, Context, Result}; -use spacetimedb_smoketests::{have_pnpm, parse_quickstart, requires_dotnet, workspace_root, Smoketest}; +use spacetimedb_smoketests::{parse_quickstart, requires_dotnet, requires_pnpm, workspace_root, Smoketest}; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -657,10 +657,7 @@ fn test_quickstart_csharp() { /// Run the TypeScript quickstart for server (with Rust client). #[test] fn test_quickstart_typescript() { - if !have_pnpm() { - eprintln!("Skipping test_quickstart_typescript: pnpm not available"); - return; - } + requires_pnpm!(); let mut qt = QuickstartTest::new(QuickstartConfig::typescript()); qt.run_quickstart().expect("TypeScript quickstart test failed"); From 077bc14e49e497b5b9812b5c129045c0182fd1a3 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 11:28:08 -0800 Subject: [PATCH 097/118] [tyler/translate-smoketests]: finish applying comment --- crates/smoketests/tests/smoketests/restart.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/smoketests/tests/smoketests/restart.rs b/crates/smoketests/tests/smoketests/restart.rs index 2da90a7d052..a88a906cdb0 100644 --- a/crates/smoketests/tests/smoketests/restart.rs +++ b/crates/smoketests/tests/smoketests/restart.rs @@ -166,7 +166,10 @@ fn test_add_remove_index_after_restart() { ); // Verify call works too - test.call("add", &[]).unwrap(); + let sub = test.subscribe_background(&[JOIN_QUERY], 1).unwrap(); + test.call_anon("add", &[]).unwrap(); + let results = sub.collect().unwrap(); + assert_eq!(results.len(), 1, "Expected 1 update from subscription"); // Restart before removing indices test.restart_server(); From c4377dd0d226b7ce97c56b39b47d521e8835aa2b Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 11:28:15 -0800 Subject: [PATCH 098/118] [tyler/translate-smoketests]: add missing auto_migration tests --- .../tests/smoketests/auto_migration.rs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/crates/smoketests/tests/smoketests/auto_migration.rs b/crates/smoketests/tests/smoketests/auto_migration.rs index d8b08a49b83..1ccb6ae3a2e 100644 --- a/crates/smoketests/tests/smoketests/auto_migration.rs +++ b/crates/smoketests/tests/smoketests/auto_migration.rs @@ -262,3 +262,133 @@ fn test_add_table_auto_migration() { logs ); } + +const MODULE_CODE_ADD_TABLE_COLUMNS_UPDATED: &str = r#" +use spacetimedb::{log, ReducerContext, Table}; + +#[derive(Debug)] +#[spacetimedb::table(name = person)] +pub struct Person { + #[index(btree)] + name: String, + #[default(0)] + age: u16, + #[default(19)] + mass: u16, +} + +#[spacetimedb::reducer] +pub fn add_person(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { name, age: 70, mass: 180 }); +} + +#[spacetimedb::reducer] +pub fn print_persons(ctx: &ReducerContext, prefix: String) { + for person in ctx.db.person().iter() { + log::info!("{}: {:?}", prefix, person); + } +} + +#[spacetimedb::reducer(client_disconnected)] +pub fn identity_disconnected(_ctx: &ReducerContext) { + log::info!("FIRST_UPDATE: client disconnected"); +} +"#; + +const MODULE_CODE_ADD_TABLE_COLUMNS_UPDATED_AGAIN: &str = r#" +use spacetimedb::{log, ReducerContext, Table}; + +#[derive(Debug)] +#[spacetimedb::table(name = person)] +pub struct Person { + name: String, + age: u16, + #[default(19)] + mass: u16, + #[default(160)] + height: u32, +} + +#[spacetimedb::reducer] +pub fn add_person(ctx: &ReducerContext, name: String) { + ctx.db.person().insert(Person { name, age: 70, mass: 180, height: 72 }); +} + +#[spacetimedb::reducer] +pub fn print_persons(ctx: &ReducerContext, prefix: String) { + for person in ctx.db.person().iter() { + log::info!("{}: {:?}", prefix, person); + } +} +"#; + +/// Verify schema upgrades that add columns with defaults (twice). +#[test] +fn test_add_table_columns() { + const NUM_SUBSCRIBERS: usize = 20; + + let mut test = Smoketest::builder().module_code(MODULE_CODE_BASIC).build(); + + // Subscribe to person table changes multiple times to simulate active clients + let mut subs = Vec::with_capacity(NUM_SUBSCRIBERS); + for _ in 0..NUM_SUBSCRIBERS { + subs.push(test.subscribe_background(&["select * from person"], 5).unwrap()); + } + + // Insert under initial schema + test.call("add_person", &["Robert"]).unwrap(); + + // First upgrade: add age & mass columns + test.write_module_code(MODULE_CODE_ADD_TABLE_COLUMNS_UPDATED).unwrap(); + let identity = test.database_identity.clone().unwrap(); + test.publish_module_with_options(&identity, false, true).unwrap(); + test.call("print_persons", &["FIRST_UPDATE"]).unwrap(); + + let logs1 = test.logs(100).unwrap(); + assert!( + logs1.iter().any(|l| l.contains("Disconnecting all users")), + "Expected disconnect log in logs: {:?}", + logs1 + ); + assert!( + logs1 + .iter() + .any(|l| l.contains("FIRST_UPDATE: Person { name: \"Robert\", age: 0, mass: 19 }")), + "Expected migrated person with defaults in logs: {:?}", + logs1 + ); + + let disconnect_count = logs1 + .iter() + .filter(|l| l.contains("FIRST_UPDATE: client disconnected")) + .count(); + assert_eq!( + disconnect_count, + NUM_SUBSCRIBERS + 1, + "Unexpected disconnect counts: {disconnect_count}" + ); + + // Insert new data under upgraded schema + test.call("add_person", &["Robert2"]).unwrap(); + + // Validate all subscribers were disconnected after first upgrade + for (i, sub) in subs.into_iter().enumerate() { + let rows = sub.collect().unwrap(); + assert_eq!(rows.len(), 2, "Subscriber {i} received unexpected rows: {rows:?}"); + } + + // Second upgrade + test.write_module_code(MODULE_CODE_ADD_TABLE_COLUMNS_UPDATED_AGAIN) + .unwrap(); + test.publish_module_with_options(&identity, false, true).unwrap(); + test.call("print_persons", &["UPDATE_2"]).unwrap(); + + let logs2 = test.logs(100).unwrap(); + assert!( + logs2 + .iter() + .any(|l| { l.contains("UPDATE_2: Person { name: \"Robert2\", age: 70, mass: 180, height: 160 }") }), + "Expected updated schema with default height in logs: {:?}", + logs2 + ); +} From c5cb82114f60df3689903238269793d61275b1f3 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 11:31:42 -0800 Subject: [PATCH 099/118] [tyler/translate-smoketests]: review --- crates/smoketests/tests/smoketests/auto_migration.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/smoketests/tests/smoketests/auto_migration.rs b/crates/smoketests/tests/smoketests/auto_migration.rs index 1ccb6ae3a2e..c3c0792573d 100644 --- a/crates/smoketests/tests/smoketests/auto_migration.rs +++ b/crates/smoketests/tests/smoketests/auto_migration.rs @@ -201,6 +201,8 @@ pub fn print_books(ctx: &ReducerContext, prefix: String) { fn test_add_table_auto_migration() { let mut test = Smoketest::builder().module_code(MODULE_CODE_INIT).build(); + let sub = test.subscribe_background(&["select * from person"], 4).unwrap(); + // Add initial data test.call("add_person", &["Robert", "Student"]).unwrap(); test.call("add_person", &["Julie", "Student"]).unwrap(); @@ -230,6 +232,15 @@ fn test_add_table_auto_migration() { // Add new data with updated schema test.call("add_person", &["Husserl", "Student"]).unwrap(); + + let sub_updates = sub.collect().unwrap(); + assert_eq!( + sub_updates.len(), + 4, + "Expected 4 subscription updates, got {}: {:?}", + sub_updates.len(), + sub_updates + ); test.call("add_person", &["Husserl", "Professor"]).unwrap(); test.call("add_book", &["1234567890"]).unwrap(); test.call("print_persons", &["AFTER_PERSON"]).unwrap(); From 98cf90d0a8061ff12415e5a9f2f64174cec6cf73 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 11:36:36 -0800 Subject: [PATCH 100/118] [tyler/translate-smoketests]: smoketests -> smoketest --- .cargo/config.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.cargo/config.toml b/.cargo/config.toml index 5774fce9a7a..c478751cd1b 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -6,6 +6,7 @@ bump-versions = "run -p upgrade-version --" llm = "run --package xtask-llm-benchmark --bin llm_benchmark --" ci = "run -p ci --" smoketest = "run -p xtask-smoketest -- smoketest" +smoketests = "smoketest" [target.x86_64-pc-windows-msvc] # Use a different linker. Otherwise, the build fails with some obscure linker error that From 4ed11a79bea00fb8eefcc361ca01daf79eba6282 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 11:36:40 -0800 Subject: [PATCH 101/118] [tyler/translate-smoketests]: fix build --- crates/smoketests/tests/smoketests/auto_migration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/smoketests/tests/smoketests/auto_migration.rs b/crates/smoketests/tests/smoketests/auto_migration.rs index c3c0792573d..8f06ae76e4e 100644 --- a/crates/smoketests/tests/smoketests/auto_migration.rs +++ b/crates/smoketests/tests/smoketests/auto_migration.rs @@ -338,7 +338,7 @@ pub fn print_persons(ctx: &ReducerContext, prefix: String) { fn test_add_table_columns() { const NUM_SUBSCRIBERS: usize = 20; - let mut test = Smoketest::builder().module_code(MODULE_CODE_BASIC).build(); + let mut test = Smoketest::builder().module_code(MODULE_CODE_SIMPLE).build(); // Subscribe to person table changes multiple times to simulate active clients let mut subs = Vec::with_capacity(NUM_SUBSCRIBERS); From e14964227eaf306f91fb1c57e3627f493da2de7d Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 11:40:18 -0800 Subject: [PATCH 102/118] [tyler/translate-smoketests]: dotnet nuget locals all --clear --- crates/smoketests/tests/smoketests/csharp_module.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/smoketests/tests/smoketests/csharp_module.rs b/crates/smoketests/tests/smoketests/csharp_module.rs index 17698e5c4e9..e543e78599f 100644 --- a/crates/smoketests/tests/smoketests/csharp_module.rs +++ b/crates/smoketests/tests/smoketests/csharp_module.rs @@ -16,6 +16,13 @@ fn test_build_csharp_module() { // CLI is pre-built by artifact dependencies during compilation let cli_path = ensure_binaries_built(); + let status = Command::new("dotnet") + .args(["nuget", "locals", "all", "--clear"]) + .current_dir(&bindings) + .status() + .expect("Failed to clear nuget locals"); + assert!(status.success(), "Failed to clear nuget locals"); + // Install wasi-experimental workload let _status = Command::new("dotnet") .args(["workload", "install", "wasi-experimental", "--skip-manifest-update"]) From 236fd5e4d37b60cd207469a31692f7b2d3eb2ca5 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 12:00:48 -0800 Subject: [PATCH 103/118] [tyler/translate-smoketests]: reviews --- crates/smoketests/src/lib.rs | 10 +++++----- crates/smoketests/tests/smoketests/csharp_module.rs | 4 ++-- crates/smoketests/tests/smoketests/pg_wire.rs | 6 +++--- crates/smoketests/tests/smoketests/quickstart.rs | 6 +++--- crates/smoketests/tests/smoketests/restart.rs | 10 +++++----- crates/smoketests/tests/smoketests/servers.rs | 13 +++++++++++-- 6 files changed, 29 insertions(+), 20 deletions(-) diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 90bfae7a154..8d6493daf8f 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -85,14 +85,14 @@ pub fn is_remote_server() -> bool { /// ```ignore /// #[test] /// fn test_restart() { -/// skip_if_remote!(); +/// require_local_server!(); /// let mut test = Smoketest::builder().build(); /// test.restart_server(); /// // ... /// } /// ``` #[macro_export] -macro_rules! skip_if_remote { +macro_rules! require_local_server { () => { if $crate::is_remote_server() { #[allow(clippy::disallowed_macros)] @@ -105,7 +105,7 @@ macro_rules! skip_if_remote { } #[macro_export] -macro_rules! requires_dotnet { +macro_rules! require_dotnet { () => { if !$crate::allow_dotnet() { #[allow(clippy::disallowed_macros)] @@ -121,7 +121,7 @@ macro_rules! requires_dotnet { } #[macro_export] -macro_rules! requires_psql { +macro_rules! require_psql { () => { if !$crate::have_psql() { panic!("psql not found"); @@ -130,7 +130,7 @@ macro_rules! requires_psql { } #[macro_export] -macro_rules! requires_pnpm { +macro_rules! require_pnpm { () => { if !$crate::have_pnpm() { panic!("pnpm not found"); diff --git a/crates/smoketests/tests/smoketests/csharp_module.rs b/crates/smoketests/tests/smoketests/csharp_module.rs index e543e78599f..6ad79e001be 100644 --- a/crates/smoketests/tests/smoketests/csharp_module.rs +++ b/crates/smoketests/tests/smoketests/csharp_module.rs @@ -1,6 +1,6 @@ #![allow(clippy::disallowed_macros)] use spacetimedb_guard::ensure_binaries_built; -use spacetimedb_smoketests::{requires_dotnet, workspace_root}; +use spacetimedb_smoketests::{require_dotnet, workspace_root}; use std::fs; use std::process::Command; @@ -9,7 +9,7 @@ use std::process::Command; /// Skips if dotnet 8.0+ is not available. #[test] fn test_build_csharp_module() { - requires_dotnet!(); + require_dotnet!(); let workspace = workspace_root(); let bindings = workspace.join("crates/bindings-csharp"); diff --git a/crates/smoketests/tests/smoketests/pg_wire.rs b/crates/smoketests/tests/smoketests/pg_wire.rs index f66bb13b9c5..1badec26083 100644 --- a/crates/smoketests/tests/smoketests/pg_wire.rs +++ b/crates/smoketests/tests/smoketests/pg_wire.rs @@ -1,10 +1,10 @@ #![allow(clippy::disallowed_macros)] -use spacetimedb_smoketests::{requires_psql, Smoketest}; +use spacetimedb_smoketests::{require_psql, Smoketest}; /// Test SQL output formatting via psql #[test] fn test_sql_format() { - requires_psql!(); + require_psql!(); let mut test = Smoketest::builder() .precompiled_module("pg-wire") @@ -74,7 +74,7 @@ fn test_sql_format() { /// Test failure cases #[test] fn test_failures() { - requires_psql!(); + require_psql!(); let mut test = Smoketest::builder() .precompiled_module("pg-wire") diff --git a/crates/smoketests/tests/smoketests/quickstart.rs b/crates/smoketests/tests/smoketests/quickstart.rs index a167c0885e6..c539e77e824 100644 --- a/crates/smoketests/tests/smoketests/quickstart.rs +++ b/crates/smoketests/tests/smoketests/quickstart.rs @@ -3,7 +3,7 @@ //! code from markdown docs and running it. use anyhow::{bail, Context, Result}; -use spacetimedb_smoketests::{parse_quickstart, requires_dotnet, requires_pnpm, workspace_root, Smoketest}; +use spacetimedb_smoketests::{parse_quickstart, require_dotnet, require_pnpm, workspace_root, Smoketest}; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -648,7 +648,7 @@ fn test_quickstart_rust() { /// Run the C# quickstart guides for server and client. #[test] fn test_quickstart_csharp() { - requires_dotnet!(); + require_dotnet!(); let mut qt = QuickstartTest::new(QuickstartConfig::csharp()); qt.run_quickstart().expect("C# quickstart test failed"); @@ -657,7 +657,7 @@ fn test_quickstart_csharp() { /// Run the TypeScript quickstart for server (with Rust client). #[test] fn test_quickstart_typescript() { - requires_pnpm!(); + require_pnpm!(); let mut qt = QuickstartTest::new(QuickstartConfig::typescript()); qt.run_quickstart().expect("TypeScript quickstart test failed"); diff --git a/crates/smoketests/tests/smoketests/restart.rs b/crates/smoketests/tests/smoketests/restart.rs index a88a906cdb0..f7af837cf2f 100644 --- a/crates/smoketests/tests/smoketests/restart.rs +++ b/crates/smoketests/tests/smoketests/restart.rs @@ -1,14 +1,14 @@ //! Tests for server restart behavior. //! Translated from smoketests/tests/zz_docker.py -use spacetimedb_smoketests::{skip_if_remote, Smoketest}; +use spacetimedb_smoketests::{require_local_server, Smoketest}; /// Test data persistence across server restart. /// /// This tests to see if SpacetimeDB can be queried after a restart. #[test] fn test_restart_module() { - skip_if_remote!(); + require_local_server!(); let mut test = Smoketest::builder().precompiled_module("restart-person").build(); test.call("add", &["Robert"]).unwrap(); @@ -52,7 +52,7 @@ fn test_restart_module() { /// Test SQL queries work after restart. #[test] fn test_restart_sql() { - skip_if_remote!(); + require_local_server!(); let mut test = Smoketest::builder().precompiled_module("restart-person").build(); test.call("add", &["Robert"]).unwrap(); @@ -83,7 +83,7 @@ fn test_restart_sql() { /// Test clients are auto-disconnected on restart. #[test] fn test_restart_auto_disconnect() { - skip_if_remote!(); + require_local_server!(); let mut test = Smoketest::builder() .precompiled_module("restart-connected-client") .build(); @@ -135,7 +135,7 @@ const JOIN_QUERY: &str = "select t1.* from t1 join t2 on t1.id = t2.id where t2. /// to re-use IDs. #[test] fn test_add_remove_index_after_restart() { - skip_if_remote!(); + require_local_server!(); let mut test = Smoketest::builder() .precompiled_module("add-remove-index") .autopublish(false) diff --git a/crates/smoketests/tests/smoketests/servers.rs b/crates/smoketests/tests/smoketests/servers.rs index 90c2426e0a4..4a079d74412 100644 --- a/crates/smoketests/tests/smoketests/servers.rs +++ b/crates/smoketests/tests/smoketests/servers.rs @@ -1,9 +1,11 @@ use regex::Regex; -use spacetimedb_smoketests::Smoketest; +use spacetimedb_smoketests::{require_local_server, Smoketest}; /// Verify that we can add and list server configurations #[test] fn test_servers() { + require_local_server!(); + let test = Smoketest::builder().autopublish(false).build(); // Add a test server (local-only command, no --server flag needed) @@ -19,7 +21,14 @@ fn test_servers() { .unwrap(); assert!( - output.contains("testnet.spacetimedb.com"), + Regex::new(r"Host: testnet\.spacetimedb\.com\n") + .unwrap() + .is_match(&output), + "Expected host in output: {}", + output + ); + assert!( + Regex::new(r"Protocol: https\n").unwrap().is_match(&output), "Expected host in output: {}", output ); From ac1db977cbc8fb28c379d6412cfbf4f7c8f5f91c Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 12:13:35 -0800 Subject: [PATCH 104/118] [tyler/translate-smoketests]: review things --- .../tests/smoketests/confirmed_reads.rs | 33 ++++++++---------- crates/smoketests/tests/smoketests/modules.rs | 34 ++++--------------- .../tests/smoketests/permissions.rs | 14 ++++---- crates/smoketests/tests/smoketests/servers.rs | 4 +-- 4 files changed, 28 insertions(+), 57 deletions(-) diff --git a/crates/smoketests/tests/smoketests/confirmed_reads.rs b/crates/smoketests/tests/smoketests/confirmed_reads.rs index 709556e8fa5..fd2875eb574 100644 --- a/crates/smoketests/tests/smoketests/confirmed_reads.rs +++ b/crates/smoketests/tests/smoketests/confirmed_reads.rs @@ -24,24 +24,21 @@ fn test_confirmed_reads_receive_updates() { // Collect updates let events = sub.collect().unwrap(); - assert_eq!(events.len(), 2, "Expected 2 updates, got {:?}", events); - - // Check that we got the expected inserts - let horst_insert = serde_json::json!({ - "person": { - "deletes": [], - "inserts": [{"name": "Horst"}] - } - }); - let egon_insert = serde_json::json!({ - "person": { - "deletes": [], - "inserts": [{"name": "Egon"}] - } - }); - - assert_eq!(events[0], horst_insert); - assert_eq!(events[1], egon_insert); + assert_eq!( + serde_json::json!(events), + serde_json::json!([{ + "person": { + "deletes": [], + "inserts": [{"name": "Horst"}] + } + }, + { + "person": { + "deletes": [], + "inserts": [{"name": "Egon"}] + } + }]) + ); } /// Tests that an SQL operation with confirmed=true returns a result diff --git a/crates/smoketests/tests/smoketests/modules.rs b/crates/smoketests/tests/smoketests/modules.rs index c44ab09676b..0cb9b1dc544 100644 --- a/crates/smoketests/tests/smoketests/modules.rs +++ b/crates/smoketests/tests/smoketests/modules.rs @@ -123,33 +123,11 @@ fn test_hotswap_module() { let updates = sub.collect().unwrap(); // Check that we got updates for both person inserts - assert_eq!(updates.len(), 2, "Expected 2 updates, got {:?}", updates); - - // First update should be Horst - let first = &updates[0]; - assert!( - first.get("person").is_some(), - "Expected person table in first update: {:?}", - first - ); - let inserts = &first["person"]["inserts"]; - assert!( - inserts.as_array().unwrap().iter().any(|r| r["name"] == "Horst"), - "Expected Horst in first update: {:?}", - first - ); - - // Second update should be Cindy - let second = &updates[1]; - assert!( - second.get("person").is_some(), - "Expected person table in second update: {:?}", - second - ); - let inserts = &second["person"]["inserts"]; - assert!( - inserts.as_array().unwrap().iter().any(|r| r["name"] == "Cindy"), - "Expected Cindy in second update: {:?}", - second + assert_eq!( + serde_json::json!(updates), + serde_json::json!([ + {"person": {"deletes": [], "inserts": [{"id": 1, "name": "Horst"}]}}, + {"person": {"deletes": [], "inserts": [{"id": 2, "name": "Cindy"}]}} + ]) ); } diff --git a/crates/smoketests/tests/smoketests/permissions.rs b/crates/smoketests/tests/smoketests/permissions.rs index 175b18e19a3..0b4da110a1d 100644 --- a/crates/smoketests/tests/smoketests/permissions.rs +++ b/crates/smoketests/tests/smoketests/permissions.rs @@ -153,29 +153,27 @@ fn test_private_table() { .unwrap(); test.call("do_thing", &["godmorgon"]).unwrap(); let events = sub.collect().unwrap(); - assert_eq!(events.len(), 1, "Expected 1 update, got {:?}", events); - let expected = serde_json::json!({ + let expected = serde_json::json!([{ "common_knowledge": { "deletes": [], "inserts": [{"thing": "godmorgon"}] } - }); - assert_eq!(events[0], expected); + }]); + assert_eq!(serde_json::json!(events), expected); // Subscribing to both tables returns updates for the public one only let sub = test.subscribe_background(&["SELECT * FROM *"], 1).unwrap(); test.call("do_thing", &["howdy"]).unwrap(); let events = sub.collect().unwrap(); - assert_eq!(events.len(), 1, "Expected 1 update, got {:?}", events); - let expected = serde_json::json!({ + let expected = serde_json::json!([{ "common_knowledge": { "deletes": [], "inserts": [{"thing": "howdy"}] } - }); - assert_eq!(events[0], expected); + }]); + assert_eq!(serde_json::json!(events), expected); } /// Ensure that you cannot delete a database that you do not own diff --git a/crates/smoketests/tests/smoketests/servers.rs b/crates/smoketests/tests/smoketests/servers.rs index 4a079d74412..6fa3946da2f 100644 --- a/crates/smoketests/tests/smoketests/servers.rs +++ b/crates/smoketests/tests/smoketests/servers.rs @@ -1,11 +1,9 @@ use regex::Regex; -use spacetimedb_smoketests::{require_local_server, Smoketest}; +use spacetimedb_smoketests::Smoketest; /// Verify that we can add and list server configurations #[test] fn test_servers() { - require_local_server!(); - let test = Smoketest::builder().autopublish(false).build(); // Add a test server (local-only command, no --server flag needed) From 042a61afa5ed0db67d5a7cd8f52c37f6da002d8b Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 12:23:08 -0800 Subject: [PATCH 105/118] [tyler/translate-smoketests]: cleanups --- crates/smoketests/tests/smoketests/dml.rs | 10 +++--- .../tests/smoketests/permissions.rs | 32 +++++++++++-------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/crates/smoketests/tests/smoketests/dml.rs b/crates/smoketests/tests/smoketests/dml.rs index a8a5a78ba9a..2483746238b 100644 --- a/crates/smoketests/tests/smoketests/dml.rs +++ b/crates/smoketests/tests/smoketests/dml.rs @@ -22,11 +22,11 @@ fn test_subscribe() { let updates = sub.collect().unwrap(); assert_eq!( - updates, - vec![ - serde_json::json!({"t": {"deletes": [], "inserts": [{"name": "Alice"}]}}), - serde_json::json!({"t": {"deletes": [], "inserts": [{"name": "Bob"}]}}), - ], + serde_json::json!(updates), + serde_json::json!([ + {"t": {"deletes": [], "inserts": [{"name": "Alice"}]}}, + {"t": {"deletes": [], "inserts": [{"name": "Bob"}]}}, + ]), "Expected subscription updates for Alice and Bob inserts" ); } diff --git a/crates/smoketests/tests/smoketests/permissions.rs b/crates/smoketests/tests/smoketests/permissions.rs index 0b4da110a1d..6dc7348355c 100644 --- a/crates/smoketests/tests/smoketests/permissions.rs +++ b/crates/smoketests/tests/smoketests/permissions.rs @@ -154,26 +154,30 @@ fn test_private_table() { test.call("do_thing", &["godmorgon"]).unwrap(); let events = sub.collect().unwrap(); - let expected = serde_json::json!([{ - "common_knowledge": { - "deletes": [], - "inserts": [{"thing": "godmorgon"}] - } - }]); - assert_eq!(serde_json::json!(events), expected); + assert_eq!( + serde_json::json!(events), + serde_json::json!([{ + "common_knowledge": { + "deletes": [], + "inserts": [{"thing": "godmorgon"}] + } + }]) + ); // Subscribing to both tables returns updates for the public one only let sub = test.subscribe_background(&["SELECT * FROM *"], 1).unwrap(); test.call("do_thing", &["howdy"]).unwrap(); let events = sub.collect().unwrap(); - let expected = serde_json::json!([{ - "common_knowledge": { - "deletes": [], - "inserts": [{"thing": "howdy"}] - } - }]); - assert_eq!(serde_json::json!(events), expected); + assert_eq!( + serde_json::json!(events), + serde_json::json!([{ + "common_knowledge": { + "deletes": [], + "inserts": [{"thing": "howdy"}] + } + }]) + ); } /// Ensure that you cannot delete a database that you do not own From 5c069afe316d25b2bf70ef9af6e644383696ce3a Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 13:02:03 -0800 Subject: [PATCH 106/118] [tyler/translate-smoketests]: move code --- crates/smoketests/src/lib.rs | 59 ------------------ .../smoketests/tests/smoketests/quickstart.rs | 62 ++++++++++++++++++- 2 files changed, 61 insertions(+), 60 deletions(-) diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 8d6493daf8f..366b1de5c0d 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -247,65 +247,6 @@ pub fn have_pnpm() -> bool { }) } -/// Parse code blocks from quickstart markdown documentation. -/// Extracts code blocks with the specified language tag. -/// -/// - `language`: "rust", "csharp", or "typescript" -/// - `module_name`: The name to replace "quickstart-chat" with -/// - `server`: If true, look for server code blocks (e.g. "rust server"), else client blocks -pub fn parse_quickstart(doc_content: &str, language: &str, module_name: &str, server: bool) -> String { - // Normalize line endings to Unix style (LF) for consistent regex matching - let doc_content = doc_content.replace("\r\n", "\n"); - - // Determine the codeblock language tag to search for - let codeblock_lang = if server { - if language == "typescript" { - "ts server".to_string() - } else { - format!("{} server", language) - } - } else if language == "typescript" { - "ts".to_string() - } else { - language.to_string() - }; - - // Extract code blocks with the specified language - let pattern = format!(r"```{}\n([\s\S]*?)\n```", regex::escape(&codeblock_lang)); - let re = Regex::new(&pattern).unwrap(); - let mut blocks: Vec = re - .captures_iter(&doc_content) - .map(|cap| cap.get(1).unwrap().as_str().to_string()) - .collect(); - - let mut end = String::new(); - - // C# specific fixups - if language == "csharp" { - let mut found_on_connected = false; - let mut filtered_blocks = Vec::new(); - - for mut block in blocks { - // The doc first creates an empty class Module, so we need to fixup the closing brace - if block.contains("partial class Module") { - block = block.replace("}", ""); - end = "\n}".to_string(); - } - // Remove the first `OnConnected` block, which body is later updated - if block.contains("OnConnected(DbConnection conn") && !found_on_connected { - found_on_connected = true; - continue; - } - filtered_blocks.push(block); - } - blocks = filtered_blocks; - } - - // Join blocks and replace module name - let result = blocks.join("\n").replace("quickstart-chat", module_name); - result + &end -} - /// A smoketest instance that manages a SpacetimeDB server and module project. pub struct Smoketest { /// The SpacetimeDB server guard (stops server on drop). diff --git a/crates/smoketests/tests/smoketests/quickstart.rs b/crates/smoketests/tests/smoketests/quickstart.rs index c539e77e824..257402fb3fe 100644 --- a/crates/smoketests/tests/smoketests/quickstart.rs +++ b/crates/smoketests/tests/smoketests/quickstart.rs @@ -3,7 +3,8 @@ //! code from markdown docs and running it. use anyhow::{bail, Context, Result}; -use spacetimedb_smoketests::{parse_quickstart, require_dotnet, require_pnpm, workspace_root, Smoketest}; +use regex::Regex; +use spacetimedb_smoketests::{require_dotnet, require_pnpm, workspace_root, Smoketest}; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -60,6 +61,65 @@ fn run_cmd(args: &[&str], cwd: &Path, input: Option<&str>) -> Result { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } +/// Parse code blocks from quickstart markdown documentation. +/// Extracts code blocks with the specified language tag. +/// +/// - `language`: "rust", "csharp", or "typescript" +/// - `module_name`: The name to replace "quickstart-chat" with +/// - `server`: If true, look for server code blocks (e.g. "rust server"), else client blocks +fn parse_quickstart(doc_content: &str, language: &str, module_name: &str, server: bool) -> String { + // Normalize line endings to Unix style (LF) for consistent regex matching + let doc_content = doc_content.replace("\r\n", "\n"); + + // Determine the codeblock language tag to search for + let codeblock_lang = if server { + if language == "typescript" { + "ts server".to_string() + } else { + format!("{} server", language) + } + } else if language == "typescript" { + "ts".to_string() + } else { + language.to_string() + }; + + // Extract code blocks with the specified language + let pattern = format!(r"```{}\n([\s\S]*?)\n```", regex::escape(&codeblock_lang)); + let re = Regex::new(&pattern).unwrap(); + let mut blocks: Vec = re + .captures_iter(&doc_content) + .map(|cap| cap.get(1).unwrap().as_str().to_string()) + .collect(); + + let mut end = String::new(); + + // C# specific fixups + if language == "csharp" { + let mut found_on_connected = false; + let mut filtered_blocks = Vec::new(); + + for mut block in blocks { + // The doc first creates an empty class Module, so we need to fixup the closing brace + if block.contains("partial class Module") { + block = block.replace("}", ""); + end = "\n}".to_string(); + } + // Remove the first `OnConnected` block, which body is later updated + if block.contains("OnConnected(DbConnection conn") && !found_on_connected { + found_on_connected = true; + continue; + } + filtered_blocks.push(block); + } + blocks = filtered_blocks; + } + + // Join blocks and replace module name + let result = blocks.join("\n").replace("quickstart-chat", module_name); + result + &end +} + /// Run pnpm command. fn pnpm(args: &[&str], cwd: &Path) -> Result { let mut full_args = vec!["pnpm"]; From 9e3c340613e9a27cf120bf189a33724048d5526d Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 13:29:13 -0800 Subject: [PATCH 107/118] [tyler/translate-smoketests]: failure clarity --- crates/smoketests/tests/smoketests/pg_wire.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/smoketests/tests/smoketests/pg_wire.rs b/crates/smoketests/tests/smoketests/pg_wire.rs index 1badec26083..c66b9f9429b 100644 --- a/crates/smoketests/tests/smoketests/pg_wire.rs +++ b/crates/smoketests/tests/smoketests/pg_wire.rs @@ -1,10 +1,13 @@ #![allow(clippy::disallowed_macros)] -use spacetimedb_smoketests::{require_psql, Smoketest}; +use spacetimedb_smoketests::{require_local_server, require_psql, Smoketest}; /// Test SQL output formatting via psql #[test] fn test_sql_format() { require_psql!(); + // This requires a local server because we don't have a clean way of providing + // the remote server's PG port. + require_local_server!(); let mut test = Smoketest::builder() .precompiled_module("pg-wire") @@ -75,6 +78,9 @@ fn test_sql_format() { #[test] fn test_failures() { require_psql!(); + // This requires a local server because we don't have a clean way of providing + // the remote server's PG port. + require_local_server!(); let mut test = Smoketest::builder() .precompiled_module("pg-wire") From 96b256f293967edd2de12b274dc2704a2fac0600 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 13:29:16 -0800 Subject: [PATCH 108/118] [tyler/translate-smoketests]: fixes --- crates/smoketests/tests/smoketests/views.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/smoketests/tests/smoketests/views.rs b/crates/smoketests/tests/smoketests/views.rs index 38510d59e9f..87cb10c4d56 100644 --- a/crates/smoketests/tests/smoketests/views.rs +++ b/crates/smoketests/tests/smoketests/views.rs @@ -25,7 +25,8 @@ fn test_st_view_tables() { #[test] fn test_fail_publish_namespace_collision() { let mut test = Smoketest::builder() - .precompiled_module("views-broken-namespace") + // Can't be precompiled because the code is intentionally broken + .module_code(include_str!("../../modules/views-broken-namespace/src/lib.rs")) .autopublish(false) .build(); @@ -40,7 +41,8 @@ fn test_fail_publish_namespace_collision() { #[test] fn test_fail_publish_wrong_return_type() { let mut test = Smoketest::builder() - .precompiled_module("views-broken-return-type") + // Can't be precompiled because the code is intentionally broken + .module_code(include_str!("../../modules/views-broken-return-type/src/lib.rs")) .autopublish(false) .build(); From 709d949e41ac8a7283deb25668107033d93fc632 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 14:07:47 -0800 Subject: [PATCH 109/118] [tyler/translate-smoketests]: migrate CLI tests --- .../tests/smoketests/cli/publish.rs | 95 ++++++++++--------- 1 file changed, 50 insertions(+), 45 deletions(-) diff --git a/crates/smoketests/tests/smoketests/cli/publish.rs b/crates/smoketests/tests/smoketests/cli/publish.rs index 6613b35aa27..74f92c5f1bf 100644 --- a/crates/smoketests/tests/smoketests/cli/publish.rs +++ b/crates/smoketests/tests/smoketests/cli/publish.rs @@ -1,15 +1,10 @@ //! CLI publish command tests -use spacetimedb_guard::{ensure_binaries_built, SpacetimeDbGuard}; -use std::process::Command; - -fn cli_cmd() -> Command { - Command::new(ensure_binaries_built()) -} +use spacetimedb_smoketests::{require_local_server, Smoketest}; #[test] fn cli_can_publish_spacetimedb_on_disk() { - let spacetime = SpacetimeDbGuard::spawn_in_temp_data_dir(); + let test = Smoketest::builder().autopublish(false).build(); // Workspace root for `cargo run -p ...` let workspace_dir = cargo_metadata::MetadataCommand::new().exec().unwrap().workspace_root; @@ -19,56 +14,66 @@ fn cli_can_publish_spacetimedb_on_disk() { .join("chat-console-rs") .join("spacetimedb"); - let output = cli_cmd() - .args(["publish", "--server", &spacetime.host_url.to_string(), "foobar"]) - .current_dir(&dir) - .output() - .expect("failed to execute"); - assert!( - output.status.success(), - "publish failed: {}", - String::from_utf8_lossy(&output.stderr) - ); + let dir = dir.to_string(); + let _ = test + .spacetime(&[ + "publish", + "--project-path", + &dir, + "--server", + &test.server_url, + "foobar", + ]) + .unwrap(); // Can republish without error to the same name - let output = cli_cmd() - .args(["publish", "--server", &spacetime.host_url.to_string(), "foobar"]) - .current_dir(&dir) - .output() - .expect("failed to execute"); - assert!( - output.status.success(), - "republish failed: {}", - String::from_utf8_lossy(&output.stderr) - ); + let _ = test + .spacetime(&[ + "publish", + "--project-path", + &dir, + "--server", + &test.server_url, + "foobar", + ]) + .unwrap(); } // TODO: Somewhere we should test that data is actually deleted properly in all the expected cases, // e.g. when providing --delete-data, or when there's a conflict and --delete-data=on-conflict is provided. fn migration_test(module_name: &str, republish_args: &[&str], expect_success: bool) { - let spacetime = SpacetimeDbGuard::spawn_in_temp_data_dir(); + // This only requires a local server because the module names are static + require_local_server!(); + + let test = Smoketest::builder().autopublish(false).build(); let workspace_dir = cargo_metadata::MetadataCommand::new().exec().unwrap().workspace_root; let dir = workspace_dir.join("modules").join("module-test"); - let output = cli_cmd() - .args(["publish", module_name, "--server", &spacetime.host_url.to_string()]) - .current_dir(&dir) - .output() - .expect("failed to execute"); - assert!( - output.status.success(), - "initial publish failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let output = cli_cmd() - .args(["publish", module_name, "--server", &spacetime.host_url.to_string()]) - .args(republish_args) - .current_dir(&dir) - .output() - .expect("failed to execute"); + let dir = dir.to_string(); + let _ = test + .spacetime(&[ + "publish", + "--project-path", + &dir, + "--server", + &test.server_url, + module_name, + ]) + .unwrap(); + + let dir = dir.to_string(); + let mut args = vec![ + "publish", + "--project-path", + &dir, + "--server", + &test.server_url, + module_name, + ]; + args.extend(republish_args); + let output = test.spacetime_cmd(&args); if expect_success { assert!( From fa81c0b0bb70d59347014f246868c823af2490e3 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 14:11:21 -0800 Subject: [PATCH 110/118] [tyler/translate-smoketests]: lints --- crates/guard/src/lib.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index 27b61814c7e..e4f67b5d5c1 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -152,7 +152,7 @@ impl SpacetimeDbGuard { let spawn_id = next_spawn_id(); let (child, logs, host_url, reader_threads) = Self::spawn_server(&data_dir, pg_port, spawn_id, use_installed_cli); - let guard = SpacetimeDbGuard { + SpacetimeDbGuard { child, host_url, logs, @@ -161,8 +161,7 @@ impl SpacetimeDbGuard { _data_dir_handle, reader_threads, use_installed_cli, - }; - guard + } } /// Stop the server process without dropping the guard. From 1479407ff61700822962c4b27e2356eab7d3c68d Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 14:14:48 -0800 Subject: [PATCH 111/118] [tyler/translate-smoketests]: fix --- templates/basic-rs/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/basic-rs/Cargo.toml b/templates/basic-rs/Cargo.toml index 5d1fabdc702..6704d940f83 100644 --- a/templates/basic-rs/Cargo.toml +++ b/templates/basic-rs/Cargo.toml @@ -4,4 +4,4 @@ version = "0.1.0" edition = "2021" [dependencies] -spacetimedb-sdk = "1.11.*" +spacetimedb-sdk = { path = "../../sdks/rust" } From 44271895a862919935ef557819c1e7f0bd5b4cd5 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 14:16:48 -0800 Subject: [PATCH 112/118] [bfops/fix-version]: templates/basic-rs properly uses workspace versions --- templates/basic-rs/Cargo.toml | 2 +- templates/basic-rs/spacetimedb/Cargo.toml | 2 +- tools/upgrade-version/src/main.rs | 13 ------------- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/templates/basic-rs/Cargo.toml b/templates/basic-rs/Cargo.toml index 5d1fabdc702..6704d940f83 100644 --- a/templates/basic-rs/Cargo.toml +++ b/templates/basic-rs/Cargo.toml @@ -4,4 +4,4 @@ version = "0.1.0" edition = "2021" [dependencies] -spacetimedb-sdk = "1.11.*" +spacetimedb-sdk = { path = "../../sdks/rust" } diff --git a/templates/basic-rs/spacetimedb/Cargo.toml b/templates/basic-rs/spacetimedb/Cargo.toml index 271b883365e..cf1e924422e 100644 --- a/templates/basic-rs/spacetimedb/Cargo.toml +++ b/templates/basic-rs/spacetimedb/Cargo.toml @@ -9,5 +9,5 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -spacetimedb = "1.11.*" +spacetimedb = { path = "../../../crates/bindings" } log = "0.4" diff --git a/tools/upgrade-version/src/main.rs b/tools/upgrade-version/src/main.rs index 19f5983717e..b2610a5825b 100644 --- a/tools/upgrade-version/src/main.rs +++ b/tools/upgrade-version/src/main.rs @@ -175,19 +175,6 @@ fn main() -> anyhow::Result<()> { } })?; - edit_toml("templates/basic-rs/spacetimedb/Cargo.toml", |doc| { - // Only set major.minor.* for the spacetimedb dependency. - // See https://github.com/clockworklabs/SpacetimeDB/issues/2724. - // - // Note: This is meaningfully different than setting just major.minor. - // See https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#default-requirements. - doc["dependencies"]["spacetimedb"] = toml_edit::value(wildcard_patch.clone()); - })?; - - edit_toml("templates/basic-rs/Cargo.toml", |doc| { - doc["dependencies"]["spacetimedb-sdk"] = toml_edit::value(wildcard_patch.clone()); - })?; - process_license_file("LICENSE.txt", &full_version); process_license_file("licenses/BSL.txt", &full_version); // Rebuild `Cargo.lock` From 7dde044a59d03b654dca2da7ccc7a1799bf4fd30 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 14:22:09 -0800 Subject: [PATCH 113/118] [bfops/fix-version]: commit rename from tyler PR --- templates/basic-rs/spacetimedb/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/basic-rs/spacetimedb/Cargo.toml b/templates/basic-rs/spacetimedb/Cargo.toml index cf1e924422e..408a33ee7f0 100644 --- a/templates/basic-rs/spacetimedb/Cargo.toml +++ b/templates/basic-rs/spacetimedb/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "spacetime-module" +name = "basic-rs-template-module" version = "0.1.0" edition = "2021" From db2a3459fbf718defe4d662f24e17449b9fe1d55 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 14:42:24 -0800 Subject: [PATCH 114/118] [tyler/translate-smoketests]: [REVERT THIS DEBUG CHANGE] --- tools/xtask-smoketest/src/main.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tools/xtask-smoketest/src/main.rs b/tools/xtask-smoketest/src/main.rs index b752af73bff..6b7b78f17c6 100644 --- a/tools/xtask-smoketest/src/main.rs +++ b/tools/xtask-smoketest/src/main.rs @@ -167,13 +167,10 @@ fn run_smoketest(server: Option, dotnet: bool, args: Vec) -> Res "-p", "spacetimedb-smoketests", "--no-fail-fast", + "-j1", + "--test-threads=1", ]); - // Set default parallelism if user didn't specify -j - if !args.iter().any(|a| a.starts_with("-j") || a.starts_with("--jobs")) { - cmd.args(["-j", DEFAULT_PARALLELISM]); - } - cmd } else { eprintln!("Running smoketests with cargo test...\n"); From 7a00edf7f8d98d14270302d2f01c39633e501c5d Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 15:05:49 -0800 Subject: [PATCH 115/118] [tyler/translate-smoketests]: Revert "[tyler/translate-smoketests]: [REVERT THIS DEBUG CHANGE]" This reverts commit db2a3459fbf718defe4d662f24e17449b9fe1d55. --- tools/xtask-smoketest/src/main.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/xtask-smoketest/src/main.rs b/tools/xtask-smoketest/src/main.rs index 6b7b78f17c6..b752af73bff 100644 --- a/tools/xtask-smoketest/src/main.rs +++ b/tools/xtask-smoketest/src/main.rs @@ -167,10 +167,13 @@ fn run_smoketest(server: Option, dotnet: bool, args: Vec) -> Res "-p", "spacetimedb-smoketests", "--no-fail-fast", - "-j1", - "--test-threads=1", ]); + // Set default parallelism if user didn't specify -j + if !args.iter().any(|a| a.starts_with("-j") || a.starts_with("--jobs")) { + cmd.args(["-j", DEFAULT_PARALLELISM]); + } + cmd } else { eprintln!("Running smoketests with cargo test...\n"); From 3159aacbe3f5921edb8230b0b26361d8b24e3c32 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 15:05:59 -0800 Subject: [PATCH 116/118] [tyler/translate-smoketests]: REVERT THIS DEBUG CHANGE --- tools/xtask-smoketest/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/xtask-smoketest/src/main.rs b/tools/xtask-smoketest/src/main.rs index b752af73bff..ff0ff299e85 100644 --- a/tools/xtask-smoketest/src/main.rs +++ b/tools/xtask-smoketest/src/main.rs @@ -133,7 +133,7 @@ fn build_precompiled_modules() -> Result<()> { /// Default parallelism for smoketests. /// 16 was found to be optimal - higher values cause OS scheduler overhead. -const DEFAULT_PARALLELISM: &str = "16"; +const DEFAULT_PARALLELISM: &str = "1"; fn run_smoketest(server: Option, dotnet: bool, args: Vec) -> Result<()> { // 1. Build binaries first (single process, no race) From ebc6b6446fc3d7a39228d5f42812f25e1c4e47d4 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 15:53:58 -0800 Subject: [PATCH 117/118] [tyler/translate-smoketests]: debug --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ddc459b0b0..f1cb3faeaa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,9 @@ jobs: with: run_install: true + - name: Check for pnpm + run: pnpm --version + - name: Install psql (Windows) if: runner.os == 'Windows' run: choco install psql -y --no-progress @@ -1076,4 +1079,4 @@ jobs: exit 1 fi - echo "No Python smoketest changes detected." \ No newline at end of file + echo "No Python smoketest changes detected." From 5dc9d431428a78971921d14192ba0d4cc66e8e4c Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Fri, 30 Jan 2026 16:08:31 -0800 Subject: [PATCH 118/118] [tyler/translate-smoketests]: fix --test-threads --- .github/workflows/ci.yml | 5 ++++- tools/ci/src/main.rs | 2 +- tools/xtask-smoketest/src/main.rs | 9 ++++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1cb3faeaa5..aff9df19d7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,7 +117,10 @@ jobs: uses: taiki-e/install-action@nextest - name: Run smoketests - run: cargo ci smoketests + # --test-threads=1 eliminates contention in the C# tests where they fight over bindings + # build artifacts. + # It also seemed to improve performance a fair amount (11m -> 6m) + run: cargo ci smoketests -- --test-threads=1 smoketests-python: needs: [lints] diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index e71c24a3922..c4c74cfacf6 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -467,7 +467,7 @@ fn main() -> Result<()> { // - Running in release mode with optimal parallelism cmd( "cargo", - ["smoketest"] + ["smoketest", "--"] .into_iter() .chain(smoketest_args.iter().map(|s| s.as_str()).clone()), ) diff --git a/tools/xtask-smoketest/src/main.rs b/tools/xtask-smoketest/src/main.rs index ff0ff299e85..44bb0a4b004 100644 --- a/tools/xtask-smoketest/src/main.rs +++ b/tools/xtask-smoketest/src/main.rs @@ -133,7 +133,7 @@ fn build_precompiled_modules() -> Result<()> { /// Default parallelism for smoketests. /// 16 was found to be optimal - higher values cause OS scheduler overhead. -const DEFAULT_PARALLELISM: &str = "1"; +const DEFAULT_PARALLELISM: &str = "16"; fn run_smoketest(server: Option, dotnet: bool, args: Vec) -> Result<()> { // 1. Build binaries first (single process, no race) @@ -170,7 +170,10 @@ fn run_smoketest(server: Option, dotnet: bool, args: Vec) -> Res ]); // Set default parallelism if user didn't specify -j - if !args.iter().any(|a| a.starts_with("-j") || a.starts_with("--jobs")) { + if !args + .iter() + .any(|a| a.starts_with("-j") || a.starts_with("--jobs") || a.starts_with("--test-threads")) + { cmd.args(["-j", DEFAULT_PARALLELISM]); } @@ -182,7 +185,7 @@ fn run_smoketest(server: Option, dotnet: bool, args: Vec) -> Res cmd.args(["test", "--release", "-p", "spacetimedb-smoketests"]); cmd }; - let status = cmd.arg("--").args(&args).status()?; + let status = cmd.args(&args).status()?; ensure!(status.success(), "Tests failed"); Ok(())