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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46627105cc7..e566a971c52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,9 +18,107 @@ concurrency: cancel-in-progress: true jobs: - docker_smoketests: + smoketests: needs: [lints] name: Smoketests + 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 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/target + 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 + 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: 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: | + # 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 + + # 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 + + - 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] @@ -92,8 +190,10 @@ 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 @@ -104,6 +204,7 @@ jobs: # 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: | @@ -116,14 +217,18 @@ 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' + - name: Install python deps run: python -m pip install -r smoketests/requirements.txt - - name: Run smoketests + + - name: Run Python smoketests # Note: clear_database and replication only work in private - run: cargo ci smoketests -- ${{ matrix.smoketest_args }} -x clear_database replication teams + 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 @@ -906,3 +1011,32 @@ jobs: repo: targetRepo, run_id: runId, }); + + warn-python-smoketests: + 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 + with: + fetch-depth: 0 + + - name: Fail if Python smoketests were modified + run: | + 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." + 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 + + echo "No Python smoketest changes detected." 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 diff --git a/Cargo.lock b/Cargo.lock index a29fa112502..1dc7c0d8d95 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" @@ -7442,7 +7450,6 @@ name = "spacetimedb-cli" version = "1.11.3" dependencies = [ "anyhow", - "assert_cmd", "base64 0.21.7", "bytes", "cargo_metadata", @@ -7468,7 +7475,6 @@ dependencies = [ "names", "notify 7.0.0", "percent-encoding", - "predicates", "pretty_assertions", "quick-xml 0.31.0", "regex", @@ -7485,7 +7491,6 @@ dependencies = [ "spacetimedb-codegen", "spacetimedb-data-structures", "spacetimedb-fs-utils", - "spacetimedb-guard", "spacetimedb-jsonwebtoken", "spacetimedb-lib 1.11.3", "spacetimedb-paths", @@ -8217,6 +8222,21 @@ dependencies = [ "tokio-tungstenite", ] +[[package]] +name = "spacetimedb-smoketests" +version = "1.11.3" +dependencies = [ + "anyhow", + "assert_cmd", + "cargo_metadata", + "predicates", + "regex", + "serde_json", + "spacetimedb-guard", + "tempfile", + "toml 0.8.23", +] + [[package]] name = "spacetimedb-snapshot" version = "1.11.3" @@ -11033,6 +11053,14 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "xtask-smoketest" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap 4.5.50", +] + [[package]] name = "xxhash-rust" version = "0.8.15" diff --git a/Cargo.toml b/Cargo.toml index ede7f97fae4..99fc0642adf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,5 @@ [workspace] +exclude = ["crates/smoketests/modules"] members = [ "crates/auth", "crates/bench", @@ -26,6 +27,7 @@ members = [ "crates/query", "crates/sats", "crates/schema", + "crates/smoketests", "sdks/rust", "sdks/unreal", "crates/snapshot", @@ -41,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", @@ -58,6 +61,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/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/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/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/guard/src/lib.rs b/crates/guard/src/lib.rs index 147bddf16b4..b6eab6a4414 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -4,18 +4,107 @@ use std::{ env, io::{BufRead, BufReader}, net::SocketAddr, + path::{Path, PathBuf}, process::{Child, Command, Stdio}, - sync::{Arc, Mutex}, + 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. +// 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 + .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(); + +/// 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. +/// +/// # Panics +/// +/// Panics if the binary does not exist. +pub fn ensure_binaries_built() -> PathBuf { + CLI_BINARY_PATH + .get_or_init(|| { + 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() + ); + } + + cli_path + }) + .clone() +} + use reqwest::blocking::Client; pub struct SpacetimeDbGuard { pub child: Child, pub host_url: String, 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, + /// 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 @@ -25,74 +114,261 @@ 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(); + let data_dir_path = temp_dir.path().to_path_buf(); - Self::spawn_spacetime_start(false, &["start", "--data-dir", &data_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: /// 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(); - Self::spawn_spacetime_start(true, &["start", "--data-dir", &data_dir]) + Self::spawn_spacetime_start_with_data_dir(true, None, data_dir_path, Some(temp_dir)) } - fn spawn_spacetime_start(use_installed_cli: bool, extra_args: &[&str]) -> 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(); + /// 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 { + Self::spawn_spacetime_start_with_data_dir(false, pg_port, data_dir, None) + } - // Workspace root for `cargo run -p ...` - let workspace_dir = env!("CARGO_MANIFEST_DIR"); + fn spawn_spacetime_start_with_data_dir( + use_installed_cli: bool, + pg_port: Option, + data_dir: PathBuf, + _data_dir_handle: Option, + ) -> Self { + let spawn_id = next_spawn_id(); - let mut args = vec![]; + 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 (child, logs) = if use_installed_cli { - args.extend_from_slice(extra_args); - args.extend_from_slice(&["--listen-addr", &address]); + let address = "127.0.0.1:0".to_string(); + let data_dir_str = data_dir.display().to_string(); + 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"); - 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 host_url = format!("http://{}", listen_addr); + let guard = SpacetimeDbGuard { + child, + host_url, + logs, + 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 { - Self::build_prereqs(workspace_dir); - args.extend(vec!["run", "-p", "spacetimedb-cli", "--"]); - args.extend(extra_args); - args.extend(["--listen-addr", &address]); - - let cmd = Command::new("cargo"); - Self::spawn_child(cmd, workspace_dir, &args) - }; - - // Parse the actual bound address from logs. - 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 }; - guard.wait_until_http_ready(Duration::from_secs(10)); - guard + // Use the built CLI (common case) + let (child, logs, host_url, reader_threads) = Self::spawn_server(&data_dir, pg_port, spawn_id); + SpacetimeDbGuard { + child, + host_url, + logs, + pg_port, + data_dir, + _data_dir_handle, + reader_threads, + } + } + } + + /// 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(); } - // 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)); + /// 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) { + 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); + + // 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 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 result = self.child.kill(); + eprintln!("[KILL] kill result for pid={}: {:?}", pid, result); } + + 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); } - fn spawn_child(mut cmd: Command, workspace_dir: &str, args: &[&str]) -> (Child, Arc>) { + /// Spawns a new server process with the given data directory. + /// 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()); + + 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); + eprintln!("[SPAWN-{:03}] Child spawned pid={}", spawn_id, child.id()); + + // Wait for the server to be ready + 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() { + eprintln!("[SPAWN-{:03}] HTTP ready at {}", spawn_id, host_url); + return (child, logs, host_url, reader_threads); + } + } + 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], + 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) @@ -101,37 +377,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) { @@ -155,30 +460,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 } @@ -195,9 +531,7 @@ fn parse_listen_addr_from_line(line: &str) -> Option { impl Drop for SpacetimeDbGuard { fn drop(&mut self) { - // Best-effort cleanup. - 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/Cargo.toml b/crates/smoketests/Cargo.toml new file mode 100644 index 00000000000..8bb517ec567 --- /dev/null +++ b/crates/smoketests/Cargo.toml @@ -0,0 +1,22 @@ +[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_json.workspace = true +toml.workspace = true +regex.workspace = true +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 new file mode 100644 index 00000000000..4888fa05829 --- /dev/null +++ b/crates/smoketests/DEVELOP.md @@ -0,0 +1,98 @@ +# Smoketests Development Guide + +## Running Tests + +### Recommended: cargo smoketest + +```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 +# Option 1: Use cargo smoketest (always rebuilds first) +cargo smoketest + +# 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. + +To check which binary you're testing against: +```bash +ls -la target/debug/spacetimedb-cli* # Check modification time +``` + +### 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, but you must rebuild first: + +```bash +cargo build -p spacetimedb-cli -p spacetimedb-standalone +cargo test -p spacetimedb-smoketests +``` + +## 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/modules/Cargo.lock b/crates/smoketests/modules/Cargo.lock new file mode 100644 index 00000000000..fa409a4851b --- /dev/null +++ b/crates/smoketests/modules/Cargo.lock @@ -0,0 +1,1042 @@ +# 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 = "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" +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" +version = "0.1.0" +dependencies = [ + "log", + "paste", + "spacetimedb", +] + +[[package]] +name = "smoketest-module-autoinc-unique" +version = "0.1.0" +dependencies = [ + "log", + "paste", + "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-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" +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-modules-breaking" +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-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" +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-upload-module-2" +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..cacef1f1e3c --- /dev/null +++ b/crates/smoketests/modules/Cargo.toml @@ -0,0 +1,83 @@ +# 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", + "rls-no-filter", + "rls-with-filter", + "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", + "modules-add-table", + "upload-module-2", + "hotswap-basic", + "hotswap-updated", + + # 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 (all 10 integer types in one module each) + "autoinc-basic", + "autoinc-unique", +] + +[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/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/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/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/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/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-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/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/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/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 new file mode 100644 index 00000000000..ebd9adeeadf --- /dev/null +++ b/crates/smoketests/src/lib.rs @@ -0,0 +1,1299 @@ +#![allow(clippy::disallowed_macros)] +//! 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. +//! +//! # 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: +//! +//! ```bash +//! cargo smoketest # Run all smoketests +//! cargo smoketest -- test_name # Run specific tests +//! cargo xtask smoketest -- --help # See all options +//! ``` +//! +//! # 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"); +//! } +//! ``` + +pub mod modules; + +use anyhow::{bail, Context, Result}; +use regex::Regex; +use spacetimedb_guard::{ensure_binaries_built, SpacetimeDbGuard}; +use std::env; +use std::fs; +use std::path::PathBuf; +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) => {{ + let start = Instant::now(); + let result = $expr; + let elapsed = start.elapsed(); + eprintln!("[TIMING] {}: {:?}", $label, elapsed); + result + }}; +} + +/// Returns the workspace root directory. +pub 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() +} + +/// Returns the shared target directory for smoketest module builds. +/// +/// 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(); + 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}; + 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). + /// 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). + 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, + /// 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. +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, + precompiled_module: Option, + bindings_features: Vec, + extra_deps: String, + autopublish: bool, + pg_port: Option, +} + +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, + precompiled_module: None, + 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()); + 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(); + 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 (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 + /// + /// 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(); + + // 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 + 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()); + + // 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); + + let cargo_toml = format!( + r#"[package] +name = "{}" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +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"); + + // 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"); + + eprintln!("[TIMING] project setup: {:?}", project_setup_start.elapsed()); + } + + let config_path = project_dir.path().join("config.toml"); + let mut smoketest = Smoketest { + guard, + project_dir, + database_identity: None, + server_url, + config_path, + module_name, + precompiled_wasm_path, + }; + + if self.autopublish { + smoketest.publish_module().expect("Failed to publish module"); + } + + eprintln!("[TIMING] total build: {:?}", build_start.elapsed()); + smoketest + } +} + +impl Smoketest { + /// Creates a new builder for configuring a smoketest. + pub fn builder() -> SmoketestBuilder { + 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. + /// + /// # 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) { + 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 = 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 + .strip_prefix("http://") + .or_else(|| self.server_url.strip_prefix("https://")) + .unwrap_or(&self.server_url) + } + + /// 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.as_ref().and_then(|g| g.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. + /// + /// Returns the command output. The command is run but not yet asserted. + /// 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 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 {}: {:?}", cmd_name, start.elapsed()); + output + } + + /// 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() { + 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. + /// + /// 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). + 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 mut cmd = Command::new(&cli_path); + cmd.args(["build", "--project-path", project_path]) + .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()); + output + } + + /// 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) + } + + /// 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) + } + + /// 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 + 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); + 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", + "--server", + &self.server_url, + "--bin-path", + &wasm_path_str, + "--yes", + ]; + + if clear { + args.push("--clear-database"); + } + + if break_clients { + args.push("--break-clients"); + } + + let name_owned; + if let Some(n) = name { + name_owned = n.to_string(); + args.push(&name_owned); + } + + let output = self.spacetime(&args)?; + 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(); + 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 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 mut cmd_args = vec!["call", "--server", &self.server_url, "--", 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", "--server", &self.server_url, "--", identity.as_str(), name]; + cmd_args.extend(args); + + 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")?; + + 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", + "--server", + &self.server_url, + "--confirmed", + 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 n_str = n.to_string(); + + let output = self.spacetime(&[ + "logs", + "--server", + &self.server_url, + "--format=json", + "-n", + &n_str, + "--", + identity, + ])?; + + output + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).context("Failed to parse log record")) + .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<()> { + let cli_path = ensure_binaries_built(); + 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([ + "--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:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + 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 { + 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; + + // 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(); + + // 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\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())?; + 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. + /// 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 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![ + "--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"); + if confirmed { + args.push("--confirmed"); + } + args.push("--"); + cmd.args(&args) + .args(queries) + .stdout(Stdio::piped()) + .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 { + 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() + .context("No database published")? + .clone(); + + 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(), + 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 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(), + }) + } +} + +/// 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(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); + } + } + + // 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); + } + + 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") +} + +#[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/src/modules.rs b/crates/smoketests/src/modules.rs new file mode 100644 index 00000000000..761893ad1be --- /dev/null +++ b/crates/smoketests/src/modules.rs @@ -0,0 +1,121 @@ +//! 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. +//! +//! 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; +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. +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 + 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. +/// +/// 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(); + + let Ok(entries) = std::fs::read_dir(&target) else { + return reg; + }; + + 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; + } + + // Extract module name: smoketest_module_foo_bar.wasm -> foo-bar + let module_name = filename + .strip_prefix("smoketest_module_") + .unwrap() + .strip_suffix(".wasm") + .unwrap() + .replace('_', "-"); + + reg.insert(module_name, path); + } + + reg +} + +#[cfg(test)] +mod tests { + #[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); + } +} 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/smoketests/add_remove_index.rs b/crates/smoketests/tests/smoketests/add_remove_index.rs new file mode 100644 index 00000000000..d8ba3413165 --- /dev/null +++ b/crates/smoketests/tests/smoketests/add_remove_index.rs @@ -0,0 +1,42 @@ +use spacetimedb_smoketests::Smoketest; + +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() + .precompiled_module("add-remove-index") + .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.use_precompiled_module("add-remove-index-indexed"); + test.publish_module_named(&name, false).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. + 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 new file mode 100644 index 00000000000..101694372ac --- /dev/null +++ b/crates/smoketests/tests/smoketests/auto_inc.rs @@ -0,0 +1,76 @@ +use spacetimedb_smoketests::Smoketest; + +const INT_TYPES: &[&str] = &["u8", "u16", "u32", "u64", "u128", "i8", "i16", "i32", "i64", "i128"]; + +#[test] +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_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 + ); + } +} diff --git a/crates/smoketests/tests/smoketests/auto_migration.rs b/crates/smoketests/tests/smoketests/auto_migration.rs new file mode 100644 index 00000000000..d8b08a49b83 --- /dev/null +++ b/crates/smoketests/tests/smoketests/auto_migration.rs @@ -0,0 +1,264 @@ +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/smoketests/call.rs b/crates/smoketests/tests/smoketests/call.rs new file mode 100644 index 00000000000..9c033979bb7 --- /dev/null +++ b/crates/smoketests/tests/smoketests/call.rs @@ -0,0 +1,212 @@ +use spacetimedb_smoketests::Smoketest; + +/// Check calling a reducer (no return) and procedure (return) +#[test] +fn test_call_reducer_procedure() { + let test = Smoketest::builder() + .precompiled_module("call-reducer-procedure") + .build(); + + // Reducer returns empty + let msg = test.call("say_hello", &[]).unwrap(); + assert_eq!(msg.trim(), ""); + + // Procedure returns a value + let msg = test.call("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() + .precompiled_module("call-reducer-procedure") + .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() + ); +} + +/// Check calling into a database with no reducers/procedures raises error +#[test] +fn test_call_empty_errors() { + let test = Smoketest::builder().precompiled_module("call-empty").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/smoketests/cli/dev.rs b/crates/smoketests/tests/smoketests/cli/dev.rs new file mode 100644 index 00000000000..012d513212a --- /dev/null +++ b/crates/smoketests/tests/smoketests/cli/dev.rs @@ -0,0 +1,87 @@ +//! CLI dev command tests + +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..9d88f6e0820 --- /dev/null +++ b/crates/smoketests/tests/smoketests/cli/mod.rs @@ -0,0 +1,3 @@ +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..6613b35aa27 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 + +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..acfdcbf00bc --- /dev/null +++ b/crates/smoketests/tests/smoketests/cli/server.rs @@ -0,0 +1,22 @@ +//! CLI server command tests + +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/smoketests/client_connection_errors.rs b/crates/smoketests/tests/smoketests/client_connection_errors.rs new file mode 100644 index 00000000000..dc6d435e0ff --- /dev/null +++ b/crates/smoketests/tests/smoketests/client_connection_errors.rs @@ -0,0 +1,56 @@ +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(); + + // 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() + .precompiled_module("client-connection-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 + ); + + // 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/confirmed_reads.rs b/crates/smoketests/tests/smoketests/confirmed_reads.rs new file mode 100644 index 00000000000..709556e8fa5 --- /dev/null +++ b/crates/smoketests/tests/smoketests/confirmed_reads.rs @@ -0,0 +1,60 @@ +//! 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; + +/// Tests that subscribing with confirmed=true receives updates +#[test] +fn test_confirmed_reads_receive_updates() { + let test = Smoketest::builder().precompiled_module("confirmed-reads").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 (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(); + + 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().precompiled_module("confirmed-reads").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/smoketests/connect_disconnect_from_cli.rs b/crates/smoketests/tests/smoketests/connect_disconnect_from_cli.rs new file mode 100644 index 00000000000..c7259d63eb3 --- /dev/null +++ b/crates/smoketests/tests/smoketests/connect_disconnect_from_cli.rs @@ -0,0 +1,26 @@ +use spacetimedb_smoketests::Smoketest; + +/// 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().precompiled_module("connect-disconnect").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/smoketests/create_project.rs b/crates/smoketests/tests/smoketests/create_project.rs new file mode 100644 index 00000000000..1c77559661e --- /dev/null +++ b/crates/smoketests/tests/smoketests/create_project.rs @@ -0,0 +1,72 @@ +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/smoketests/csharp_module.rs b/crates/smoketests/tests/smoketests/csharp_module.rs new file mode 100644 index 00000000000..4f72f3e4c1b --- /dev/null +++ b/crates/smoketests/tests/smoketests/csharp_module.rs @@ -0,0 +1,113 @@ +#![allow(clippy::disallowed_macros)] +use spacetimedb_guard::ensure_binaries_built; +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"); + // CLI is pre-built by artifact dependencies during compilation + let cli_path = ensure_binaries_built(); + + // 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 in Release configuration + let status = Command::new("dotnet") + .args(["pack", "-c", "Release"]) + .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 + // Use to avoid inheriting sources from machine/user config + let packed_projects = ["BSATN.Runtime", "Runtime"]; + let mut sources = + String::from(" \n \n"); + 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/smoketests/default_module_clippy.rs b/crates/smoketests/tests/smoketests/default_module_clippy.rs new file mode 100644 index 00000000000..f32b0fd94e2 --- /dev/null +++ b/crates/smoketests/tests/smoketests/default_module_clippy.rs @@ -0,0 +1,51 @@ +//! These tests verify that the Rust module templates have no clippy warnings. + +use std::path::PathBuf; +use std::process::Command; + +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(&template_module_dir) + .output() + .expect("Failed to run cargo clippy"); + + assert!( + output.status.success(), + "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/crates/smoketests/tests/smoketests/delete_database.rs b/crates/smoketests/tests/smoketests/delete_database.rs new file mode 100644 index 00000000000..c101304563f --- /dev/null +++ b/crates/smoketests/tests/smoketests/delete_database.rs @@ -0,0 +1,39 @@ +use spacetimedb_smoketests::Smoketest; +use std::thread; +use std::time::Duration; + +/// 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() + .precompiled_module("delete-database") + .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", "--server", &test.server_url, &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/smoketests/describe.rs b/crates/smoketests/tests/smoketests/describe.rs new file mode 100644 index 00000000000..72d66c57008 --- /dev/null +++ b/crates/smoketests/tests/smoketests/describe.rs @@ -0,0 +1,37 @@ +use spacetimedb_smoketests::Smoketest; + +/// Check describing a module +#[test] +fn test_describe() { + let test = Smoketest::builder().precompiled_module("describe").build(); + + let identity = test.database_identity.as_ref().unwrap(); + + // Describe the whole module + test.spacetime(&["describe", "--server", &test.server_url, "--json", identity]) + .unwrap(); + + // Describe a specific reducer + 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(); +} diff --git a/crates/smoketests/tests/smoketests/detect_wasm_bindgen.rs b/crates/smoketests/tests/smoketests/detect_wasm_bindgen.rs new file mode 100644 index 00000000000..8ff2224cdab --- /dev/null +++ b/crates/smoketests/tests/smoketests/detect_wasm_bindgen.rs @@ -0,0 +1,66 @@ +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/smoketests/dml.rs b/crates/smoketests/tests/smoketests/dml.rs new file mode 100644 index 00000000000..a8a5a78ba9a --- /dev/null +++ b/crates/smoketests/tests/smoketests/dml.rs @@ -0,0 +1,32 @@ +use spacetimedb_smoketests::Smoketest; + +/// Test that we receive subscription updates from DML +#[test] +fn test_subscribe() { + use std::thread; + use std::time::Duration; + + 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(); + + // 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/smoketests/domains.rs b/crates/smoketests/tests/smoketests/domains.rs new file mode 100644 index 00000000000..5acf85e848f --- /dev/null +++ b/crates/smoketests/tests/smoketests/domains.rs @@ -0,0 +1,114 @@ +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", "--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", "--server", &test.server_url, "--to", &rand_name, identity]) + .unwrap(); + + // Now logs should work with the new name + 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]); + 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", + "--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" + ); +} + +/// 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(); +} diff --git a/crates/smoketests/tests/smoketests/energy.rs b/crates/smoketests/tests/smoketests/energy.rs new file mode 100644 index 00000000000..b92f0e47e25 --- /dev/null +++ b/crates/smoketests/tests/smoketests/energy.rs @@ -0,0 +1,14 @@ +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", "--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/smoketests/fail_initial_publish.rs b/crates/smoketests/tests/smoketests/fail_initial_publish.rs new file mode 100644 index 00000000000..70ac7e1dd29 --- /dev/null +++ b/crates/smoketests/tests/smoketests/fail_initial_publish.rs @@ -0,0 +1,76 @@ +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'"); +"#; + +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", "--server", &test.server_url, "--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.use_precompiled_module("fail-initial-publish-fixed"); + test.publish_module_named(&name, false).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: {}", + 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", "--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: {}", + describe_output + ); +} diff --git a/crates/smoketests/tests/smoketests/filtering.rs b/crates/smoketests/tests/smoketests/filtering.rs new file mode 100644 index 00000000000..c1b9a8a85ea --- /dev/null +++ b/crates/smoketests/tests/smoketests/filtering.rs @@ -0,0 +1,292 @@ +use spacetimedb_smoketests::Smoketest; + +/// Test filtering reducers +#[test] +fn test_filtering() { + 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(); + 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/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/smoketests/module_nested_op.rs b/crates/smoketests/tests/smoketests/module_nested_op.rs new file mode 100644 index 00000000000..7cdfdcb7042 --- /dev/null +++ b/crates/smoketests/tests/smoketests/module_nested_op.rs @@ -0,0 +1,19 @@ +use spacetimedb_smoketests::Smoketest; + +/// This tests uploading a basic module and calling some functions and checking logs afterwards. +#[test] +fn test_module_nested_op() { + 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(); + 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/smoketests/modules.rs b/crates/smoketests/tests/smoketests/modules.rs new file mode 100644 index 00000000000..c44ab09676b --- /dev/null +++ b/crates/smoketests/tests/smoketests/modules.rs @@ -0,0 +1,155 @@ +use spacetimedb_smoketests::Smoketest; + +/// 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 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 (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(); + 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.use_precompiled_module("modules-add-table"); + 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().precompiled_module("modules-basic").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!"))); +} + +/// 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 + ); +} diff --git a/crates/smoketests/tests/smoketests/namespaces.rs b/crates/smoketests/tests/smoketests/namespaces.rs new file mode 100644 index 00000000000..d50770d439f --- /dev/null +++ b/crates/smoketests/tests/smoketests/namespaces.rs @@ -0,0 +1,106 @@ +use spacetimedb_smoketests::Smoketest; +use std::fs; +use std::path::{Path, PathBuf}; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .to_path_buf() +} + +/// 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().is_some_and(|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() + .precompiled_module("namespaces") + .autopublish(false) + .build(); + + 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(); + + 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() + .precompiled_module("namespaces") + .autopublish(false) + .build(); + + let tmpdir = tempfile::tempdir().expect("Failed to create temp dir"); + 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.to_str().unwrap(), + ]) + .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/smoketests/new_user_flow.rs b/crates/smoketests/tests/smoketests/new_user_flow.rs new file mode 100644 index 00000000000..a153e44c3c2 --- /dev/null +++ b/crates/smoketests/tests/smoketests/new_user_flow.rs @@ -0,0 +1,43 @@ +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() { + let mut test = Smoketest::builder() + .precompiled_module("new-user-flow") + .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/smoketests/panic.rs b/crates/smoketests/tests/smoketests/panic.rs new file mode 100644 index 00000000000..3af42e149f3 --- /dev/null +++ b/crates/smoketests/tests/smoketests/panic.rs @@ -0,0 +1,38 @@ +use spacetimedb_smoketests::Smoketest; + +/// Tests to check if a SpacetimeDB module can handle a panic without corrupting +#[test] +fn test_panic() { + let test = Smoketest::builder().precompiled_module("panic").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 + ); +} + +/// Tests to ensure an error message returned from a reducer gets printed to logs +#[test] +fn test_reducer_error_message() { + let test = Smoketest::builder().precompiled_module("panic-error").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/smoketests/permissions.rs b/crates/smoketests/tests/smoketests/permissions.rs new file mode 100644 index 00000000000..175b18e19a3 --- /dev/null +++ b/crates/smoketests/tests/smoketests/permissions.rs @@ -0,0 +1,212 @@ +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] +fn test_private_table() { + let test = Smoketest::builder().precompiled_module("permissions-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); + + // 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 +#[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", "--server", &test.server_url, &identity, "--yes"]); + assert!(result.is_err(), "Expected delete to fail for non-owner"); +} + +/// 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().precompiled_module("permissions-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/smoketests/pg_wire.rs b/crates/smoketests/tests/smoketests/pg_wire.rs new file mode 100644 index 00000000000..a2aad486a67 --- /dev/null +++ b/crates/smoketests/tests/smoketests/pg_wire.rs @@ -0,0 +1,126 @@ +#![allow(clippy::disallowed_macros)] +use spacetimedb_smoketests::{have_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; + } + + let mut test = Smoketest::builder() + .precompiled_module("pg-wire") + .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() + .precompiled_module("pg-wire") + .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/smoketests/quickstart.rs b/crates/smoketests/tests/smoketests/quickstart.rs new file mode 100644 index 00000000000..d4e6394adf7 --- /dev/null +++ b/crates/smoketests/tests/smoketests/quickstart.rs @@ -0,0 +1,670 @@ +#![allow(clippy::disallowed_macros)] +//! 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(()) +} + +/// 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::from(" \n"); + source_lines.push_str(" \n"); + 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<()> { + // 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", "-c", "Release"]) + .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 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 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") { + mappings.push(("nuget.org".to_string(), "*".to_string())); + } + + // Write config + let config = create_nuget_config(&sources, &mappings); + fs::write(&nuget_config_path, config)?; + + Ok(()) +} + +/// 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(); + + // 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(&[ + "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(&["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", + "--server", + &self.test.server_url, + "--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(&[ + "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('\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"); +} diff --git a/crates/smoketests/tests/smoketests/restart.rs b/crates/smoketests/tests/smoketests/restart.rs new file mode 100644 index 00000000000..2da90a7d052 --- /dev/null +++ b/crates/smoketests/tests/smoketests/restart.rs @@ -0,0 +1,180 @@ +//! Tests for server restart behavior. +//! Translated from smoketests/tests/zz_docker.py + +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(); + + // 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(); + 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() { + skip_if_remote!(); + let mut test = Smoketest::builder().precompiled_module("restart-person").build(); + + test.call("add", &["Robert"]).unwrap(); + 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(); + assert!( + output.contains("Samantha"), + "Expected 'Samantha' in SQL output: {}", + output + ); +} + +/// 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(); + + // 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 + ); +} + +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() { + skip_if_remote!(); + let mut test = Smoketest::builder() + .precompiled_module("add-remove-index") + .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.use_precompiled_module("add-remove-index-indexed"); + 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.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 new file mode 100644 index 00000000000..91059b3edb5 --- /dev/null +++ b/crates/smoketests/tests/smoketests/rls.rs @@ -0,0 +1,119 @@ +use spacetimedb_smoketests::Smoketest; + +/// Tests for querying tables with RLS rules +#[test] +fn test_rls_rules() { + let test = Smoketest::builder().precompiled_module("rls").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 +------"#, + ); +} + +/// 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 + ); +} diff --git a/crates/smoketests/tests/smoketests/schedule_reducer.rs b/crates/smoketests/tests/smoketests/schedule_reducer.rs new file mode 100644 index 00000000000..863321c228c --- /dev/null +++ b/crates/smoketests/tests/smoketests/schedule_reducer.rs @@ -0,0 +1,85 @@ +use spacetimedb_smoketests::Smoketest; +use std::thread; +use std::time::Duration; + +/// Ensure cancelling a reducer works +#[test] +fn test_cancel_reducer() { + let test = Smoketest::builder().precompiled_module("schedule-cancel").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 + ); +} + +/// 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().precompiled_module("schedule-subscribe").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().precompiled_module("schedule-subscribe").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 + ); +} + +/// Check that volatile_nonatomic_schedule_immediate works +#[test] +fn test_volatile_nonatomic_schedule_immediate() { + let test = Smoketest::builder().precompiled_module("schedule-volatile").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 + ); +} diff --git a/crates/smoketests/tests/smoketests/servers.rs b/crates/smoketests/tests/smoketests/servers.rs new file mode 100644 index 00000000000..90c2426e0a4 --- /dev/null +++ b/crates/smoketests/tests/smoketests/servers.rs @@ -0,0 +1,89 @@ +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 (local-only command, no --server flag needed) + 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 (local-only command) + 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 + ); + + // Add the local test server to the config so we can check its fingerprint + test.spacetime(&[ + "server", + "add", + "--url", + &test.server_url, + "test-local", + "--no-fingerprint", + ]) + .unwrap(); + + // Check fingerprint commands (local-only command) + 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"), + "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 (local-only command) + test.spacetime(&["server", "add", "--url", "https://foo.com", "foo", "--no-fingerprint"]) + .unwrap(); + + // Edit the server (local-only command) + test.spacetime(&[ + "server", + "edit", + "foo", + "--url", + "https://edited-testnet.spacetimedb.com", + "--new-name", + "edited-testnet", + "--no-fingerprint", + "--yes", + ]) + .unwrap(); + + // Verify the edit (local-only command) + 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 + ); +} diff --git a/crates/smoketests/tests/smoketests/sql.rs b/crates/smoketests/tests/smoketests/sql.rs new file mode 100644 index 00000000000..ca8b1318f31 --- /dev/null +++ b/crates/smoketests/tests/smoketests/sql.rs @@ -0,0 +1,65 @@ +use spacetimedb_smoketests::Smoketest; + +/// This test is designed to test the format of the output of sql queries +#[test] +fn test_sql_format() { + let test = Smoketest::builder().precompiled_module("sql-format").build(); + + test.call("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 = ()))"#, + ); +} diff --git a/crates/smoketests/tests/smoketests/timestamp_route.rs b/crates/smoketests/tests/smoketests/timestamp_route.rs new file mode 100644 index 00000000000..f3cdf1f494b --- /dev/null +++ b/crates/smoketests/tests/smoketests/timestamp_route.rs @@ -0,0 +1,53 @@ +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] + ); +} diff --git a/crates/smoketests/tests/smoketests/views.rs b/crates/smoketests/tests/smoketests/views.rs new file mode 100644 index 00000000000..bf91a91809b --- /dev/null +++ b/crates/smoketests/tests/smoketests/views.rs @@ -0,0 +1,152 @@ +use spacetimedb_smoketests::Smoketest; + +/// Tests that views populate the st_view_* system tables +#[test] +fn test_st_view_tables() { + let test = Smoketest::builder().precompiled_module("views-basic").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" + ); +} + +/// Tests that views can be queried over HTTP SQL +#[test] +fn test_http_sql_views() { + let test = Smoketest::builder().precompiled_module("views-sql").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().precompiled_module("views-sql").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"#, + ); +} 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/templates/basic-rs/spacetimedb/Cargo.toml b/templates/basic-rs/spacetimedb/Cargo.toml index 271b883365e..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" @@ -9,5 +9,5 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -spacetimedb = "1.11.*" +spacetimedb = { path = "../../../crates/bindings" } log = "0.4" diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index d0eeb7a4ace..6095ee2db6d 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(); @@ -237,8 +228,20 @@ 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. @@ -399,13 +402,16 @@ fn main() -> Result<()> { } Some(CiCmd::Smoketests { args: smoketest_args }) => { - let python = infer_python(); + // 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( - python, - ["-m", "smoketests"] + "cargo", + ["smoketest"] .into_iter() - .map(|s| s.to_string()) - .chain(smoketest_args), + .chain(smoketest_args.iter().map(|s| s.as_str()).clone()), ) .run()?; } 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..86e8030b2f6 --- /dev/null +++ b/tools/xtask-smoketest/src/main.rs @@ -0,0 +1,197 @@ +#![allow(clippy::disallowed_macros)] +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, + + /// 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, + }, +} + +#[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, + server, + args, + } => run_smoketest(server, args), + } +} + +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", + ]); + + // 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 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. +/// 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<()> { + // 1. Build binaries first (single process, no race) + build_binaries()?; + + // 2. Build pre-compiled modules (this also warms the WASM dependency cache) + build_precompiled_modules()?; + + // 4. 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); + + // 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"); + } + let mut cmd = Command::new("cargo"); + 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")) + { + 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"); + } + 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"); + Ok(()) +}