From 117b8513838018a5883ce5d4e34d41b416e86d5c Mon Sep 17 00:00:00 2001 From: pederbe Date: Thu, 10 Sep 2026 20:00:19 +0200 Subject: [PATCH 1/2] Require precompiled Rust modules in smoketest construction --- crates/smoketests/DEVELOP.md | 46 +-- crates/smoketests/modules/Cargo.toml | 7 +- .../column-defaults-initial/Cargo.toml | 11 + .../column-defaults-initial/src/lib.rs | 4 + .../column-defaults-updated/Cargo.toml | 11 + .../column-defaults-updated/src/lib.rs | 32 ++ .../modules/http-handlers-tutorial/Cargo.toml | 11 + .../modules/http-handlers-tutorial/build.rs | 25 ++ .../modules/http-handlers-tutorial/src/lib.rs | 1 + crates/smoketests/src/lib.rs | 277 +++++------------- .../tests/cluster/column_defaults.rs | 48 +-- .../smoketests/tests/cluster/http_routes.rs | 9 +- crates/smoketests/tests/cluster/modules.rs | 16 + crates/smoketests/tests/cluster/views.rs | 16 - .../tests/standalone/detect_wasm_bindgen.rs | 18 +- crates/smoketests/tests/standalone/views.rs | 17 +- 16 files changed, 247 insertions(+), 302 deletions(-) create mode 100644 crates/smoketests/modules/column-defaults-initial/Cargo.toml create mode 100644 crates/smoketests/modules/column-defaults-initial/src/lib.rs create mode 100644 crates/smoketests/modules/column-defaults-updated/Cargo.toml create mode 100644 crates/smoketests/modules/column-defaults-updated/src/lib.rs create mode 100644 crates/smoketests/modules/http-handlers-tutorial/Cargo.toml create mode 100644 crates/smoketests/modules/http-handlers-tutorial/build.rs create mode 100644 crates/smoketests/modules/http-handlers-tutorial/src/lib.rs diff --git a/crates/smoketests/DEVELOP.md b/crates/smoketests/DEVELOP.md index 6acba49aea2..43698b527ae 100644 --- a/crates/smoketests/DEVELOP.md +++ b/crates/smoketests/DEVELOP.md @@ -10,7 +10,8 @@ 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) +2. Builds the Rust fixture workspace in `crates/smoketests/modules/` to WASM +3. Runs all smoketests in parallel using nextest (or cargo test if nextest isn't installed) To run specific tests: ```bash @@ -51,7 +52,8 @@ cargo smoketest # Option 2: Manually rebuild, then run tests directly cargo build -p spacetimedb-cli -p spacetimedb-standalone --features spacetimedb-standalone/allow_loopback_http_for_tests -cargo nextest run -p spacetimedb-smoketests +cargo build --manifest-path crates/smoketests/modules/Cargo.toml --workspace --release --target wasm32-unknown-unknown +CARGO_BUILD_PROFILE=debug cargo nextest run -p spacetimedb-smoketests ``` **If you run `cargo nextest run` or `cargo test` directly without rebuilding, @@ -75,44 +77,46 @@ Standard `cargo test` also works, but you must rebuild first: ```bash cargo build -p spacetimedb-cli -p spacetimedb-standalone --features spacetimedb-standalone/allow_loopback_http_for_tests -cargo test -p spacetimedb-smoketests +cargo build --manifest-path crates/smoketests/modules/Cargo.toml --workspace --release --target wasm32-unknown-unknown +CARGO_BUILD_PROFILE=debug 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 +Rust fixtures are compiled once during warmup and reused across tests. Ordinary +tests then start a server and publish the selected WASM without invoking Cargo. +Tests of build diagnostics explicitly compile temporary modules. When running tests in parallel, resource contention increases individual test times but reduces overall runtime. ## Writing Tests -See existing tests for patterns. Key points: +Add a fixture crate under `crates/smoketests/modules/`, following an existing +crate's `Cargo.toml` and `src/lib.rs`, and list it in that workspace's members. +The package name `smoketest-module-example` makes it available as `example`: ```rust use spacetimedb_smoketests::Smoketest; -const MODULE_CODE: &str = r#" -use spacetimedb::{ReducerContext, Table}; - -#[spacetimedb::table(accessor = 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) + .precompiled_module("example") .build(); test.call("add", &["42"]).unwrap(); test.assert_sql("SELECT * FROM example", "value\n-----\n42"); } ``` + +Place the table and `add` reducer in the fixture's `src/lib.rs`. Use +`test.use_precompiled_module("example-updated")` to switch fixtures for migration +tests. If no module is selected, publishing uses the precompiled `noop` fixture. +`autopublish(false)` leaves the database unpublished and does not need that fixture +until a publish is requested. + +For tests that expect Rust build failures, use `build_rust_module(source, extra_deps)` +and assert the specific diagnostic in its raw output. This helper runs +`spacetime build` without starting a server. Keep ordinary test modules in the +fixture workspace. The `http-handlers-tutorial` fixture shows how a build script +can compile examples directly from current documentation during warmup. diff --git a/crates/smoketests/modules/Cargo.toml b/crates/smoketests/modules/Cargo.toml index 63dc67687eb..8d507a6367f 100644 --- a/crates/smoketests/modules/Cargo.toml +++ b/crates/smoketests/modules/Cargo.toml @@ -14,7 +14,7 @@ members = [ # Views tests "views-basic", - # "views-broken-namespace" - intentionally broken, uses runtime compilation + # "views-broken-namespace" is exercised by an explicit build-failure test "views-broken-return-type", "views-sql", "views-auto-migrate", @@ -47,6 +47,10 @@ members = [ "call-empty", "call-many", + # Column defaults + "column-defaults-initial", + "column-defaults-updated", + # Auto-migration tests "auto-migration-simple", "auto-migration-incompatible", @@ -63,6 +67,7 @@ members = [ "auto-migration-drop-event-table-after", # HTTP tests + "http-handlers-tutorial", "http-egress", "http-routes", "http-routes-example", diff --git a/crates/smoketests/modules/column-defaults-initial/Cargo.toml b/crates/smoketests/modules/column-defaults-initial/Cargo.toml new file mode 100644 index 00000000000..7adbdcb2553 --- /dev/null +++ b/crates/smoketests/modules/column-defaults-initial/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "smoketest-module-column-defaults-initial" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true diff --git a/crates/smoketests/modules/column-defaults-initial/src/lib.rs b/crates/smoketests/modules/column-defaults-initial/src/lib.rs new file mode 100644 index 00000000000..b1daede0071 --- /dev/null +++ b/crates/smoketests/modules/column-defaults-initial/src/lib.rs @@ -0,0 +1,4 @@ +#[spacetimedb::table(accessor = defaults_test_table, public)] +pub struct DefaultsTestTable { + pub id: u32, +} diff --git a/crates/smoketests/modules/column-defaults-updated/Cargo.toml b/crates/smoketests/modules/column-defaults-updated/Cargo.toml new file mode 100644 index 00000000000..cb009247597 --- /dev/null +++ b/crates/smoketests/modules/column-defaults-updated/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "smoketest-module-column-defaults-updated" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true diff --git a/crates/smoketests/modules/column-defaults-updated/src/lib.rs b/crates/smoketests/modules/column-defaults-updated/src/lib.rs new file mode 100644 index 00000000000..a404e9b5289 --- /dev/null +++ b/crates/smoketests/modules/column-defaults-updated/src/lib.rs @@ -0,0 +1,32 @@ +#[spacetimedb::table(accessor = defaults_test_table, public)] +pub struct DefaultsTestTable { + pub id: u32, + #[default(true)] + pub bool_value: bool, + #[default(-8)] + pub i8_value: i8, + #[default(8)] + pub u8_value: u8, + #[default(-16)] + pub i16_value: i16, + #[default(16)] + pub u16_value: u16, + #[default(-32)] + pub i32_value: i32, + #[default(32)] + pub u32_value: u32, + #[default(-64)] + pub i64_value: i64, + #[default(64)] + pub u64_value: u64, + #[default(32.5)] + pub f32_positive_value: f32, + #[default(-32.5)] + pub f32_negative_value: f32, + #[default(64.25)] + pub f64_positive_value: f64, + #[default(-64.25)] + pub f64_negative_value: f64, + #[default("default string")] + pub string_value: String, +} diff --git a/crates/smoketests/modules/http-handlers-tutorial/Cargo.toml b/crates/smoketests/modules/http-handlers-tutorial/Cargo.toml new file mode 100644 index 00000000000..de8bc6958cf --- /dev/null +++ b/crates/smoketests/modules/http-handlers-tutorial/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "smoketest-module-http-handlers-tutorial" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb.workspace = true diff --git a/crates/smoketests/modules/http-handlers-tutorial/build.rs b/crates/smoketests/modules/http-handlers-tutorial/build.rs new file mode 100644 index 00000000000..20925f91771 --- /dev/null +++ b/crates/smoketests/modules/http-handlers-tutorial/build.rs @@ -0,0 +1,25 @@ +use std::{env, fs, path::PathBuf}; + +fn main() { + let doc_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../../docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md"); + println!("cargo:rerun-if-changed={}", doc_path.display()); + let doc = fs::read_to_string(&doc_path).expect("Failed to read HTTP handlers tutorial"); + let doc = doc.replace("\r\n", "\n"); + let blocks: Vec<_> = doc + .split("```rust\n") + .skip(1) + .map(|block| { + block + .split_once("\n```") + .expect("Unterminated Rust code block in HTTP handlers tutorial") + .0 + }) + .collect(); + assert!( + !blocks.is_empty(), + "No Rust code blocks found in HTTP handlers tutorial" + ); + let out_path = PathBuf::from(env::var_os("OUT_DIR").unwrap()).join("module.rs"); + fs::write(out_path, blocks.join("\n\n")).expect("Failed to write HTTP handlers tutorial module"); +} diff --git a/crates/smoketests/modules/http-handlers-tutorial/src/lib.rs b/crates/smoketests/modules/http-handlers-tutorial/src/lib.rs new file mode 100644 index 00000000000..8252a5f76ba --- /dev/null +++ b/crates/smoketests/modules/http-handlers-tutorial/src/lib.rs @@ -0,0 +1 @@ +include!(concat!(env!("OUT_DIR"), "/module.rs")); diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 3ec8f5b7141..ab6e8ce187d 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -6,9 +6,9 @@ //! //! # 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. +//! Rust modules are pre-compiled during the warmup phase. Use +//! `Smoketest::builder().precompiled_module("name")` to select a module from +//! `crates/smoketests/modules/`. The default module is `noop`. //! //! # Running Smoketests //! @@ -25,28 +25,13 @@ //! ```ignore //! use spacetimedb_smoketests::Smoketest; //! -//! const MODULE_CODE: &str = r#" -//! use spacetimedb::{table, reducer}; -//! -//! #[spacetimedb::table(accessor = 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) +//! let test = Smoketest::builder() +//! .precompiled_module("noop") //! .build(); //! -//! test.call("add", &["Alice"]).unwrap(); -//! test.assert_sql("SELECT * FROM person", "name\n-----\nAlice"); +//! test.call("noop", &[]).unwrap(); //! } //! ``` @@ -218,10 +203,8 @@ pub fn patch_module_cargo_to_local_bindings(module_dir: &Path) -> Result<()> { /// 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. +/// Explicit build-diagnostic tests share this directory to cache dependencies. +/// Cargo serializes their builds through directory locking. fn shared_target_dir() -> PathBuf { static TARGET_DIR: OnceLock = OnceLock::new(); TARGET_DIR @@ -233,6 +216,55 @@ fn shared_target_dir() -> PathBuf { .clone() } +/// Runs `spacetime build` on a temporary Rust project and returns its raw output. +/// +/// Use this for tests of build diagnostics, such as rejected wasm-bindgen imports. +/// Ordinary smoketests should add a precompiled fixture to `crates/smoketests/modules/`. +/// This does not start a server or publish a module, and removes the source project +/// after the command finishes. `extra_deps` is appended to `[dependencies]`. +pub fn build_rust_module(source: &str, extra_deps: &str) -> Output { + let cli_path = ensure_binaries_built(); + let project_dir = tempfile::tempdir().expect("Failed to create temporary Rust project"); + let workspace_root = workspace_root(); + let bindings_path = workspace_root + .join("crates/bindings") + .display() + .to_string() + .replace('\\', "/"); + let module_name = format!("smoketest_module_{}", random_string()); + let cargo_toml = format!( + r#"[package] +name = "{module_name}" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb = {{ path = "{bindings_path}", features = ["unstable"] }} +log = "0.4" +{extra_deps} +"# + ); + fs::create_dir(project_dir.path().join("src")).expect("Failed to create Rust source directory"); + fs::write(project_dir.path().join("Cargo.toml"), cargo_toml).expect("Failed to write Cargo.toml"); + fs::write(project_dir.path().join("src/lib.rs"), source).expect("Failed to write Rust module source"); + fs::copy( + workspace_root.join("rust-toolchain.toml"), + project_dir.path().join("rust-toolchain.toml"), + ) + .expect("Failed to copy rust-toolchain.toml"); + + Command::new(cli_path) + .args(["build", "--module-path"]) + .arg(project_dir.path()) + .current_dir(project_dir.path()) + .env("CARGO_TARGET_DIR", shared_target_dir()) + .output() + .expect("Failed to execute spacetime build") +} + /// Generates a random lowercase alphabetic string suitable for database names. pub fn random_string() -> String { use std::time::{SystemTime, UNIX_EPOCH}; @@ -470,19 +502,12 @@ pub struct Smoketest { _data_dir_fixture: Option, /// Temporary directory containing the module project. pub project_dir: tempfile::TempDir, - /// Additional features for the spacetimedb bindings dependency. - pub bindings_features: Vec, - /// Additional dependencies to add to the module's Cargo.toml. - pub extra_deps: String, /// 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, /// Optional path to a specific CLI binary to run for this test. @@ -674,8 +699,12 @@ impl<'a> PublishBuilder<'a> { ]; } } - } else if let Some(module_path) = smoketest.precompiled_wasm_path.as_ref() { + } else { post_publish_step = None; + if smoketest.precompiled_wasm_path.is_none() { + smoketest.use_precompiled_module("noop"); + } + let module_path = smoketest.precompiled_wasm_path.as_ref().unwrap(); // Use pre-compiled WASM directly (no build needed) eprintln!("[TIMING] spacetime build: skipped (using precompiled)"); module_args = vec![ @@ -685,18 +714,6 @@ impl<'a> PublishBuilder<'a> { .context("Invalid precompiled module path")? .to_string(), ]; - } else { - post_publish_step = None; - // Rust is built separately to use the shared Cargo target cache; publishing the resulting WASM - // by path avoids rebuilding it. This is a harness optimization, not a Rust requirement. - module_args = vec![ - "--bin-path".to_string(), - smoketest - .prepare_rust_module_internal()? - .to_str() - .context("Invalid Rust module path")? - .to_string(), - ]; } let identity = smoketest.publish_module_internal( @@ -794,11 +811,8 @@ impl<'a> SubscribeBuilder<'a> { /// Builder for creating `Smoketest` instances. pub struct SmoketestBuilder { - module_code: Option, precompiled_module: Option, data_dir_fixture: Option, - bindings_features: Vec, - extra_deps: String, autopublish: bool, pg_port: Option, server_url_override: Option, @@ -820,11 +834,8 @@ impl SmoketestBuilder { /// Creates a new builder with default settings. pub fn new() -> Self { Self { - module_code: None, precompiled_module: None, data_dir_fixture: None, - bindings_features: vec!["unstable".to_string()], - extra_deps: String::new(), autopublish: true, pg_port: None, server_url_override: None, @@ -861,13 +872,7 @@ impl SmoketestBuilder { 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. + /// Selects a pre-compiled module instead of the default `noop` module. /// /// Pre-compiled modules are built during the warmup phase and stored in /// `crates/smoketests/modules/target/`. This eliminates per-test compilation @@ -889,18 +894,6 @@ impl SmoketestBuilder { 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 { @@ -911,8 +904,9 @@ impl SmoketestBuilder { /// 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. + /// creates a temporary project directory, and optionally publishes a precompiled + /// module. The default `noop` module is only resolved when publishing, so tests + /// using `autopublish(false)` need no module artifacts unless they publish one. /// /// When `SPACETIME_REMOTE_SERVER` is set, tests run against the remote server /// instead of spawning a local server. Tests that require local server control @@ -991,12 +985,6 @@ impl SmoketestBuilder { 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()); - let config_path = project_dir.path().join("config.toml"); if let Ok(base_config_path) = std::env::var("SPACETIME_SMOKETEST_BASE_CONFIG_PATH") { fs::copy(&base_config_path, &config_path) @@ -1009,28 +997,10 @@ impl SmoketestBuilder { database_identity: fixture_identity, server_url, config_path, - module_name, precompiled_wasm_path: precompiled_wasm_path.clone(), cli_path: self.cli_path.clone(), - bindings_features: self.bindings_features.clone(), - extra_deps: self.extra_deps.clone(), }; - // Only set up project structure if not using precompiled module - if precompiled_wasm_path.is_none() { - let module_code = self.module_code.unwrap_or_else(|| { - r#"use spacetimedb::ReducerContext; - -#[spacetimedb::reducer] -pub fn noop(_ctx: &ReducerContext) {} -"# - .to_string() - }); - smoketest.write_module_code(&module_code).unwrap(); - - eprintln!("[TIMING] project setup: {:?}", project_setup_start.elapsed()); - } - if self.autopublish { smoketest.publish().run().expect("Failed to publish module"); } @@ -1328,57 +1298,6 @@ impl Smoketest { Ok(module_path) } - /// 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 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" -{} -"#, - self.module_name, bindings_path_str, features_str, self.extra_deps - ); - 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 @@ -1410,61 +1329,10 @@ log = "0.4" Ok(()) } - /// Runs `spacetime build` and returns the raw output. - /// - /// Use this when you need to check for build failures (e.g., wasm_bindgen detection). - pub fn spacetime_build(&self) -> Output { - let start = Instant::now(); - let project_path = self.project_dir.path().to_str().unwrap(); - let cli_path = self.cli_path(); - - let mut cmd = Command::new(&cli_path); - cmd.args(["build", "--module-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 - } - pub fn publish(&mut self) -> PublishBuilder<'_> { PublishBuilder::new(self) } - /// Builds the Rust module using the target directory shared by smoketests. - /// - /// The caller publishes the resulting WASM with `--bin-path` to avoid a second build. Rust modules - /// could instead use `--module-path` if the publish command inherited this shared target directory. - fn prepare_rust_module_internal(&self) -> Result { - // Build the WASM module from source - let project_path = self.project_dir.path().to_str().unwrap(); - let build_start = Instant::now(); - let cli_path = self.cli_path(); - let target_dir = shared_target_dir(); - - let mut build_cmd = Command::new(&cli_path); - build_cmd - .args(["build", "--module-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"); - eprintln!("[TIMING] spacetime build: {:?}", build_start.elapsed()); - - if !build_output.status.success() { - bail!( - "spacetime build failed:\nstdout: {}\nstderr: {}", - String::from_utf8_lossy(&build_output.stdout), - String::from_utf8_lossy(&build_output.stderr) - ); - } - - // Construct the wasm path using the unique module name - let wasm_filename = format!("{}.wasm", self.module_name); - Ok(target_dir.join("wasm32-unknown-unknown/release").join(wasm_filename)) - } - /// Publishes the prepared module and stores the database identity. /// /// If `name` is provided, the database will be published with that name. @@ -1943,6 +1811,19 @@ fn normalize_whitespace(s: &str) -> String { mod tests { use super::*; + #[test] + fn test_unpublished_builder_needs_no_module() { + let test = Smoketest::builder() + .server_url("http://127.0.0.1:1") + .cli_path("unused-cli") + .autopublish(false) + .build(); + assert!(test.database_identity.is_none()); + assert!(test.precompiled_wasm_path.is_none()); + assert!(!test.project_dir.path().join("Cargo.toml").exists()); + assert!(!test.project_dir.path().join("src").exists()); + } + #[test] fn test_normalize_whitespace() { let input = "hello \nworld \n foo "; diff --git a/crates/smoketests/tests/cluster/column_defaults.rs b/crates/smoketests/tests/cluster/column_defaults.rs index 258cd6507cf..feef4e2c5ae 100644 --- a/crates/smoketests/tests/cluster/column_defaults.rs +++ b/crates/smoketests/tests/cluster/column_defaults.rs @@ -56,9 +56,11 @@ fn test_source_defaults(language: ModuleLanguage, project_name: &str, initial: & #[test] fn test_rust_column_defaults() { - let mut test = Smoketest::builder().module_code(RUST_INITIAL).build(); + let mut test = Smoketest::builder() + .precompiled_module("column-defaults-initial") + .build(); test_defaults(&mut test, |test| { - test.write_module_code(RUST_UPDATED).unwrap(); + test.use_precompiled_module("column-defaults-updated"); test.publish() .current_database() .unwrap() @@ -96,48 +98,6 @@ fn test_cpp_column_defaults() { test_source_defaults(ModuleLanguage::Cpp, "column-defaults-cpp", CPP_INITIAL, CPP_UPDATED); } -const RUST_INITIAL: &str = r#" -#[spacetimedb::table(accessor = defaults_test_table, public)] -pub struct DefaultsTestTable { - pub id: u32, -} -"#; - -const RUST_UPDATED: &str = r#" -#[spacetimedb::table(accessor = defaults_test_table, public)] -pub struct DefaultsTestTable { - pub id: u32, - #[default(true)] - pub bool_value: bool, - #[default(-8)] - pub i8_value: i8, - #[default(8)] - pub u8_value: u8, - #[default(-16)] - pub i16_value: i16, - #[default(16)] - pub u16_value: u16, - #[default(-32)] - pub i32_value: i32, - #[default(32)] - pub u32_value: u32, - #[default(-64)] - pub i64_value: i64, - #[default(64)] - pub u64_value: u64, - #[default(32.5)] - pub f32_positive_value: f32, - #[default(-32.5)] - pub f32_negative_value: f32, - #[default(64.25)] - pub f64_positive_value: f64, - #[default(-64.25)] - pub f64_negative_value: f64, - #[default("default string")] - pub string_value: String, -} -"#; - const TYPESCRIPT_INITIAL: &str = r#" import { schema, t, table } from "spacetimedb/server"; diff --git a/crates/smoketests/tests/cluster/http_routes.rs b/crates/smoketests/tests/cluster/http_routes.rs index d5ebf77bce2..bf0b46414c0 100644 --- a/crates/smoketests/tests/cluster/http_routes.rs +++ b/crates/smoketests/tests/cluster/http_routes.rs @@ -1290,12 +1290,9 @@ fn csharp_handle_request_body() { /// Validates the Rust example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. #[test] fn http_handlers_tutorial_say_hello_route_works() { - let module_code = extract_code_blocks( - &workspace_root().join("docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md"), - r"```rust\n([\s\S]*?)\n```", - "rust", - ); - let test = Smoketest::builder().module_code(&module_code).build(); + let test = Smoketest::builder() + .precompiled_module("http-handlers-tutorial") + .build(); let identity = test.database_identity.as_ref().expect("database identity missing"); let url = format!("{}/v1/database/{}/route/say-hello", test.server_url, identity); diff --git a/crates/smoketests/tests/cluster/modules.rs b/crates/smoketests/tests/cluster/modules.rs index f086249085b..928919a5c28 100644 --- a/crates/smoketests/tests/cluster/modules.rs +++ b/crates/smoketests/tests/cluster/modules.rs @@ -1,5 +1,21 @@ use spacetimedb_smoketests::Smoketest; +#[test] +fn test_default_noop_is_precompiled() { + let test = Smoketest::builder().build(); + assert!(!test.project_dir.path().join("Cargo.toml").exists()); + test.call("noop", &[]).unwrap(); +} + +#[test] +fn test_default_noop_can_be_published_later() { + let mut test = Smoketest::builder().autopublish(false).build(); + assert!(test.database_identity.is_none()); + test.publish().run().unwrap(); + assert!(!test.project_dir.path().join("Cargo.toml").exists()); + test.call("noop", &[]).unwrap(); +} + /// Test publishing a module without the --delete-data option #[test] fn test_module_update() { diff --git a/crates/smoketests/tests/cluster/views.rs b/crates/smoketests/tests/cluster/views.rs index c592cce3070..ae05a6e4d73 100644 --- a/crates/smoketests/tests/cluster/views.rs +++ b/crates/smoketests/tests/cluster/views.rs @@ -263,22 +263,6 @@ fn test_st_view_tables() { ); } -/// 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() - // Can't be precompiled because the code is intentionally broken - .module_code(include_str!("../../modules/views-broken-namespace/src/lib.rs")) - .autopublish(false) - .build(); - - let result = test.publish().run(); - 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() { diff --git a/crates/smoketests/tests/standalone/detect_wasm_bindgen.rs b/crates/smoketests/tests/standalone/detect_wasm_bindgen.rs index baf95009eb4..e87d7a00cd1 100644 --- a/crates/smoketests/tests/standalone/detect_wasm_bindgen.rs +++ b/crates/smoketests/tests/standalone/detect_wasm_bindgen.rs @@ -1,4 +1,4 @@ -use spacetimedb_smoketests::Smoketest; +use spacetimedb_smoketests::build_rust_module; /// Module code that uses wasm_bindgen (should be rejected) const MODULE_CODE_WASM_BINDGEN: &str = r#" @@ -29,13 +29,7 @@ pub fn test(_ctx: &ReducerContext) { /// Standalone-only: this validates local CLI build diagnostics without publishing a module. #[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(); + let output = build_rust_module(MODULE_CODE_WASM_BINDGEN, r#"wasm-bindgen = "0.2""#); assert!(!output.status.success(), "Expected build to fail with wasm_bindgen"); let stderr = String::from_utf8_lossy(&output.stderr); @@ -50,13 +44,7 @@ fn test_detect_wasm_bindgen() { /// Standalone-only: this validates local CLI build diagnostics without publishing a module. #[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(); + let output = build_rust_module(MODULE_CODE_GETRANDOM, r#"rand = "0.8""#); assert!(!output.status.success(), "Expected build to fail with getrandom"); let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/crates/smoketests/tests/standalone/views.rs b/crates/smoketests/tests/standalone/views.rs index b65f2ecb058..30c7f6d8d52 100644 --- a/crates/smoketests/tests/standalone/views.rs +++ b/crates/smoketests/tests/standalone/views.rs @@ -1,7 +1,22 @@ use std::path::PathBuf; use serde_json::{json, Value}; -use spacetimedb_smoketests::{require_local_server, workspace_root, Smoketest}; +use spacetimedb_smoketests::{build_rust_module, require_local_server, workspace_root, Smoketest}; + +/// A table and view with the same accessor collide during Rust compilation. +#[test] +fn test_fail_build_namespace_collision() { + let output = build_rust_module(include_str!("../../modules/views-broken-namespace/src/lib.rs"), ""); + assert!( + !output.status.success(), + "Expected a namespace collision to fail compilation" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("the name `person` is defined multiple times"), + "Expected the duplicate person diagnostic, got: {stderr}" + ); +} const STALE_VIEW_BACKING_TABLE_FIXTURE_IDENTITY: &str = "c200f6ec405075e508c2ed6474019332d6a2a46c69614306cc4bd980e0b8b767"; From e92c16ea7f66d1fe93ab5e62d1ad0332b794d993 Mon Sep 17 00:00:00 2001 From: Peder Bergan Date: Wed, 23 Sep 2026 09:38:19 +0200 Subject: [PATCH 2/2] Precompile remaining smoketest language fixtures --- .github/workflows/ci.yml | 149 +-- Cargo.lock | 1 + crates/smoketests/DEVELOP.md | 35 +- crates/smoketests/modules/Cargo.lock | 21 + crates/smoketests/modules/cpp/CMakeLists.txt | 47 + .../cpp/column-defaults-cpp-initial.cpp | 10 + .../cpp/column-defaults-cpp-updated.cpp | 55 + .../modules/cpp/http-routes-cpp-basic.cpp | 88 ++ .../modules/cpp/http-routes-cpp-example.cpp | 95 ++ .../modules/cpp/http-routes-cpp-full-uri.cpp | 16 + .../cpp/http-routes-cpp-request-body.cpp | 58 + .../cpp/http-routes-cpp-strict-non-root.cpp | 25 + .../cpp/http-routes-cpp-strict-root.cpp | 35 + .../csharp/column-defaults-csharp-initial.cs | 11 + .../csharp/column-defaults-csharp-updated.cs | 25 + .../csharp/http-routes-csharp-basic.cs | 111 ++ .../csharp/http-routes-csharp-example.cs | 52 + .../csharp/http-routes-csharp-full-uri.cs | 21 + .../csharp/http-routes-csharp-request-body.cs | 45 + .../http-routes-csharp-strict-non-root.cs | 27 + .../csharp/http-routes-csharp-strict-root.cs | 41 + .../modules/csharp/views-count-csharp.cs | 49 + .../smoketests/modules/csharp/views-csharp.cs | 29 + .../typescript/column-defaults-ts-initial.ts | 9 + .../typescript/column-defaults-ts-updated.ts | 25 + .../http-routes-typescript-basic.ts | 61 + .../http-routes-typescript-example.ts | 33 + .../http-routes-typescript-full-uri.ts | 12 + .../http-routes-typescript-request-body.ts | 28 + .../http-routes-typescript-strict-non-root.ts | 18 + .../http-routes-typescript-strict-root.ts | 28 + .../modules/typescript/modules-basic-ts.ts | 15 + .../typescript-add-optional-columns-v1.ts | 29 + .../typescript-add-optional-columns-v2.ts | 39 + .../typescript-change-source-name-v2.ts | 26 + .../typescript/views-count-typescript.ts | 50 + .../typescript/views-subscribe-typescript.ts | 43 + crates/smoketests/src/lib.rs | 254 +---- crates/smoketests/src/modules.rs | 144 ++- crates/smoketests/src/prepare.rs | 455 ++++++++ .../tests/cluster/column_defaults.rs | 184 +-- .../smoketests/tests/cluster/http_routes.rs | 1009 +---------------- crates/smoketests/tests/cluster/views.rs | 240 +--- .../tests/standalone/change_host_type.rs | 36 +- .../typescript_index_source_name.rs | 150 +-- tools/ci/commands/smoketests/Cargo.toml | 1 + tools/ci/commands/smoketests/src/main.rs | 26 +- 47 files changed, 2080 insertions(+), 1881 deletions(-) create mode 100644 crates/smoketests/modules/cpp/CMakeLists.txt create mode 100644 crates/smoketests/modules/cpp/column-defaults-cpp-initial.cpp create mode 100644 crates/smoketests/modules/cpp/column-defaults-cpp-updated.cpp create mode 100644 crates/smoketests/modules/cpp/http-routes-cpp-basic.cpp create mode 100644 crates/smoketests/modules/cpp/http-routes-cpp-example.cpp create mode 100644 crates/smoketests/modules/cpp/http-routes-cpp-full-uri.cpp create mode 100644 crates/smoketests/modules/cpp/http-routes-cpp-request-body.cpp create mode 100644 crates/smoketests/modules/cpp/http-routes-cpp-strict-non-root.cpp create mode 100644 crates/smoketests/modules/cpp/http-routes-cpp-strict-root.cpp create mode 100644 crates/smoketests/modules/csharp/column-defaults-csharp-initial.cs create mode 100644 crates/smoketests/modules/csharp/column-defaults-csharp-updated.cs create mode 100644 crates/smoketests/modules/csharp/http-routes-csharp-basic.cs create mode 100644 crates/smoketests/modules/csharp/http-routes-csharp-example.cs create mode 100644 crates/smoketests/modules/csharp/http-routes-csharp-full-uri.cs create mode 100644 crates/smoketests/modules/csharp/http-routes-csharp-request-body.cs create mode 100644 crates/smoketests/modules/csharp/http-routes-csharp-strict-non-root.cs create mode 100644 crates/smoketests/modules/csharp/http-routes-csharp-strict-root.cs create mode 100644 crates/smoketests/modules/csharp/views-count-csharp.cs create mode 100644 crates/smoketests/modules/csharp/views-csharp.cs create mode 100644 crates/smoketests/modules/typescript/column-defaults-ts-initial.ts create mode 100644 crates/smoketests/modules/typescript/column-defaults-ts-updated.ts create mode 100644 crates/smoketests/modules/typescript/http-routes-typescript-basic.ts create mode 100644 crates/smoketests/modules/typescript/http-routes-typescript-example.ts create mode 100644 crates/smoketests/modules/typescript/http-routes-typescript-full-uri.ts create mode 100644 crates/smoketests/modules/typescript/http-routes-typescript-request-body.ts create mode 100644 crates/smoketests/modules/typescript/http-routes-typescript-strict-non-root.ts create mode 100644 crates/smoketests/modules/typescript/http-routes-typescript-strict-root.ts create mode 100644 crates/smoketests/modules/typescript/modules-basic-ts.ts create mode 100644 crates/smoketests/modules/typescript/typescript-add-optional-columns-v1.ts create mode 100644 crates/smoketests/modules/typescript/typescript-add-optional-columns-v2.ts create mode 100644 crates/smoketests/modules/typescript/typescript-change-source-name-v2.ts create mode 100644 crates/smoketests/modules/typescript/views-count-typescript.ts create mode 100644 crates/smoketests/modules/typescript/views-subscribe-typescript.ts create mode 100644 crates/smoketests/src/prepare.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19fbbb2c06a..6c6b3ac30e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,7 +288,7 @@ jobs: needs: [upload-build-artifacts-linux] name: Build smoketests (Linux) runs-on: spacetimedb-linux - timeout-minutes: 15 + timeout-minutes: 30 env: RUST_BACKTRACE: full ARTIFACT_SUFFIX: linux @@ -317,9 +317,83 @@ jobs: - name: Install cargo-nextest uses: taiki-e/install-action@nextest + - &smoketest-dotnet + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + # Node.js and pnpm are required for the TypeScript smoketests. + - &smoketest-node + name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + + - &smoketest-pnpm + uses: ./.github/actions/setup-pnpm + + - &smoketest-typescript-deps + name: Install TypeScript SDK dependencies + run: pnpm --filter spacetimedb install + + - &smoketest-emscripten + name: Install emscripten (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + git clone https://github.com/emscripten-core/emsdk.git $env:USERPROFILE\emsdk + cd $env:USERPROFILE\emsdk + .\emsdk install 4.0.21 + .\emsdk activate 4.0.21 + + - &smoketest-dotnet-workloads + name: Update dotnet workloads + if: runner.os == 'Windows' + shell: pwsh + 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 --from-previous-sdk + # Explicitly install wasi-experimental for .NET 8 SDK (needed for test_build_csharp_module) + # Create temp global.json to target .NET 8 SDK for workload install + $sdk8Json = '{"sdk":{"version":"8.0.400","rollForward":"latestFeature"}}' + $sdk8Json | Out-File -FilePath global.json -Encoding utf8 + dotnet workload install wasi-experimental + Remove-Item global.json + + - &download-build-artifacts + name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-artifacts-${{ env.ARTIFACT_SUFFIX }} + path: build-artifacts + + - &extract-build-artifacts + name: Extract build artifacts + shell: bash + run: | + archive_path="${PWD}/build-artifacts/build-support.tar.gz" + exe_suffix="" + if [[ "${RUNNER_OS}" == "Windows" ]]; then + exe_suffix=".exe" + fi + mkdir -p "${CARGO_TARGET_DIR}" + cd "${CARGO_TARGET_DIR}" + tar -xzf "${archive_path}" + test -f "release/spacetimedb-cli${exe_suffix}" + test -f "release/spacetimedb-standalone${exe_suffix}" + - name: Build smoketest dependencies and archive test binaries shell: bash run: | + if [ -f ~/emsdk/emsdk_env.sh ]; then + source ~/emsdk/emsdk_env.sh + fi cargo run --timings --package ci-smoketests -- --suite "${SMOKETEST_SUITE}" archive --archive-file smoketest-nextest.tar.zst archive_path="${PWD}/smoketest-support.tar.gz" @@ -333,6 +407,7 @@ jobs: tar -czf "${archive_path}" \ "debug/ci-smoketests${EXE_SUFFIX}" \ + smoketest-precompiled \ "${precompiled_modules[@]}" - *show-sccache-stats @@ -362,7 +437,7 @@ jobs: needs: [upload-build-artifacts-windows] name: Build smoketests (Windows) runs-on: spacetimedb-windows-runner - timeout-minutes: 15 + timeout-minutes: 30 env: RUST_BACKTRACE: full ARTIFACT_SUFFIX: windows @@ -395,29 +470,11 @@ jobs: - uses: dsherret/rust-toolchain-file@v1 - *set-default-rust-toolchain - - uses: actions/setup-dotnet@v4 - with: - global-json-file: global.json - - # Node.js and pnpm are required for the TypeScript smoketests. - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 18 - - - uses: ./.github/actions/setup-pnpm - - - name: Install TypeScript SDK dependencies - run: pnpm --filter spacetimedb install - - - name: Install emscripten (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - git clone https://github.com/emscripten-core/emsdk.git $env:USERPROFILE\emsdk - cd $env:USERPROFILE\emsdk - .\emsdk install 4.0.21 - .\emsdk activate 4.0.21 + - *smoketest-dotnet + - *smoketest-node + - *smoketest-pnpm + - *smoketest-typescript-deps + - *smoketest-emscripten - name: Install psql if: runner.os == 'Windows' @@ -431,24 +488,7 @@ jobs: # See https://github.com/clockworklabs/SpacetimeDB/pull/4399 for more background. Get-Command psql - - name: Update dotnet workloads - if: runner.os == 'Windows' - shell: pwsh - 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 --from-previous-sdk - # Explicitly install wasi-experimental for .NET 8 SDK (needed for test_build_csharp_module) - # Create temp global.json to target .NET 8 SDK for workload install - $sdk8Json = '{"sdk":{"version":"8.0.400","rollForward":"latestFeature"}}' - $sdk8Json | Out-File -FilePath global.json -Encoding utf8 - dotnet workload install wasi-experimental - Remove-Item global.json + - *smoketest-dotnet-workloads - name: Override NuGet packages shell: bash @@ -461,27 +501,8 @@ jobs: - name: Install cargo-nextest uses: taiki-e/install-action@nextest - - &download-build-artifacts - name: Download build artifacts - uses: actions/download-artifact@v4 - with: - name: build-artifacts-${{ env.ARTIFACT_SUFFIX }} - path: build-artifacts - - - &extract-build-artifacts - name: Extract build artifacts - shell: bash - run: | - archive_path="${PWD}/build-artifacts/build-support.tar.gz" - exe_suffix="" - if [[ "${RUNNER_OS}" == "Windows" ]]; then - exe_suffix=".exe" - fi - mkdir -p "${CARGO_TARGET_DIR}" - cd "${CARGO_TARGET_DIR}" - tar -xzf "${archive_path}" - test -f "release/spacetimedb-cli${exe_suffix}" - test -f "release/spacetimedb-standalone${exe_suffix}" + - *download-build-artifacts + - *extract-build-artifacts - name: Download smoketest build uses: actions/download-artifact@v4 diff --git a/Cargo.lock b/Cargo.lock index a7215a04f4a..d5ae5b78b70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1013,6 +1013,7 @@ dependencies = [ "clap 4.5.50", "duct", "spacetimedb-guard", + "spacetimedb-smoketests", "tempfile", ] diff --git a/crates/smoketests/DEVELOP.md b/crates/smoketests/DEVELOP.md index 43698b527ae..b8558b35669 100644 --- a/crates/smoketests/DEVELOP.md +++ b/crates/smoketests/DEVELOP.md @@ -10,7 +10,7 @@ cargo smoketest This command: 1. Builds `spacetimedb-cli` and `spacetimedb-standalone` binaries -2. Builds the Rust fixture workspace in `crates/smoketests/modules/` to WASM +2. Builds the Rust, TypeScript, C#, and C++ fixtures in `crates/smoketests/modules/` 3. Runs all smoketests in parallel using nextest (or cargo test if nextest isn't installed) To run specific tests: @@ -50,10 +50,9 @@ you MUST rebuild before running tests: # 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 --features spacetimedb-standalone/allow_loopback_http_for_tests -cargo build --manifest-path crates/smoketests/modules/Cargo.toml --workspace --release --target wasm32-unknown-unknown -CARGO_BUILD_PROFILE=debug cargo nextest run -p spacetimedb-smoketests +# Option 2: Prepare binaries and fixtures, then run tests directly +cargo smoketest prepare +cargo nextest run -p spacetimedb-smoketests ``` **If you run `cargo nextest run` or `cargo test` directly without rebuilding, @@ -62,7 +61,7 @@ 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 +ls -la target/release/spacetimedb-cli* # Check modification time ``` ### Why This Design? @@ -76,16 +75,23 @@ Pre-building avoids this entirely. Standard `cargo test` also works, but you must rebuild first: ```bash -cargo build -p spacetimedb-cli -p spacetimedb-standalone --features spacetimedb-standalone/allow_loopback_http_for_tests -cargo build --manifest-path crates/smoketests/modules/Cargo.toml --workspace --release --target wasm32-unknown-unknown -CARGO_BUILD_PROFILE=debug cargo test -p spacetimedb-smoketests +cargo smoketest prepare +cargo test -p spacetimedb-smoketests ``` ## Test Performance -Rust fixtures are compiled once during warmup and reused across tests. Ordinary -tests then start a server and publish the selected WASM without invoking Cargo. -Tests of build diagnostics explicitly compile temporary modules. +Fixtures are compiled once during preparation and reused across tests. Ordinary +tests start a server and publish the selected WASM or JavaScript artifact without +invoking a compiler. Tests of build diagnostics explicitly compile temporary modules. + +Preparation needs pnpm for TypeScript, .NET 10 for C#, and Emscripten for C++. +Local runs skip languages whose toolchains are unavailable; selecting one of their +fixtures then fails with a preparation hint. Use `cargo smoketest --dotnet=false` +to disable C# preparation and tests. CI archive preparation requires every enabled +toolchain. Non-Rust artifacts go in `target/smoketest-precompiled` (or under +`CARGO_TARGET_DIR`) and must travel with the Rust WASM files in the support archive. +The archive also preserves disabled C# support. When running tests in parallel, resource contention increases individual test times but reduces overall runtime. @@ -115,6 +121,11 @@ tests. If no module is selected, publishing uses the precompiled `noop` fixture. `autopublish(false)` leaves the database unpublished and does not need that fixture until a publish is requested. +For TypeScript, C#, or C++, add the source under the corresponding language +directory in `crates/smoketests/modules/` and register it in `src/prepare.rs`. +Use the same named selection interface shown above. Tutorial fixtures read the +current documentation during preparation, so changes to the examples are tested. + For tests that expect Rust build failures, use `build_rust_module(source, extra_deps)` and assert the specific diagnostic in its raw output. This helper runs `spacetime build` without starting a server. Keep ordinary test modules in the diff --git a/crates/smoketests/modules/Cargo.lock b/crates/smoketests/modules/Cargo.lock index 15b3868a84e..3f3ea5003bc 100644 --- a/crates/smoketests/modules/Cargo.lock +++ b/crates/smoketests/modules/Cargo.lock @@ -719,6 +719,20 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "smoketest-module-column-defaults-initial" +version = "0.1.0" +dependencies = [ + "spacetimedb", +] + +[[package]] +name = "smoketest-module-column-defaults-updated" +version = "0.1.0" +dependencies = [ + "spacetimedb", +] + [[package]] name = "smoketest-module-confirmed-reads" version = "0.1.0" @@ -799,6 +813,13 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "smoketest-module-http-handlers-tutorial" +version = "0.1.0" +dependencies = [ + "spacetimedb", +] + [[package]] name = "smoketest-module-http-routes" version = "0.1.0" diff --git a/crates/smoketests/modules/cpp/CMakeLists.txt b/crates/smoketests/modules/cpp/CMakeLists.txt new file mode 100644 index 00000000000..dba7b092733 --- /dev/null +++ b/crates/smoketests/modules/cpp/CMakeLists.txt @@ -0,0 +1,47 @@ +cmake_minimum_required(VERSION 3.16) +project(smoketest_cpp_module) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(SPACETIMEDB_CPP_LIBRARY_PATH "@SPACETIMEDB_CPP_LIBRARY_PATH@") + +add_executable(lib src/lib.cpp) + +target_include_directories(lib PRIVATE + ${SPACETIMEDB_CPP_LIBRARY_PATH}/include +) + +if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + target_compile_options(lib PRIVATE -fno-exceptions -O2 -g0) + target_compile_definitions(lib PRIVATE SPACETIMEDB_UNSTABLE_FEATURES) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -DSPACETIMEDB_UNSTABLE_FEATURES") +endif() + +add_subdirectory(${SPACETIMEDB_CPP_LIBRARY_PATH} ${CMAKE_CURRENT_BINARY_DIR}/spacetimedb_cpp_library) +target_link_libraries(lib PRIVATE spacetimedb_cpp_library) + +if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + set(EXPORTED_FUNCS + "['_malloc','_free','___describe_module__','___call_reducer__','___call_procedure__','___call_http_handler__']" + ) + + target_link_options(lib PRIVATE + "SHELL:-sSTANDALONE_WASM=1" + "SHELL:-sWASM=1" + "SHELL:--no-entry" + "SHELL:-sEXPORTED_FUNCTIONS=${EXPORTED_FUNCS}" + "SHELL:-sERROR_ON_UNDEFINED_SYMBOLS=1" + "SHELL:-sFILESYSTEM=0" + "SHELL:-sDISABLE_EXCEPTION_CATCHING=1" + "SHELL:-sALLOW_MEMORY_GROWTH=0" + "SHELL:-sINITIAL_MEMORY=16MB" + "SHELL:-sSUPPORT_LONGJMP=0" + "SHELL:-sSUPPORT_ERRNO=0" + "SHELL:-std=c++20" + "SHELL:-O2" + "SHELL:-g0" + ) + + set_target_properties(lib PROPERTIES OUTPUT_NAME "lib" SUFFIX ".wasm") +endif() diff --git a/crates/smoketests/modules/cpp/column-defaults-cpp-initial.cpp b/crates/smoketests/modules/cpp/column-defaults-cpp-initial.cpp new file mode 100644 index 00000000000..5431e42a796 --- /dev/null +++ b/crates/smoketests/modules/cpp/column-defaults-cpp-initial.cpp @@ -0,0 +1,10 @@ + +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +struct DefaultsTestTable { + uint32_t id; +}; +SPACETIMEDB_STRUCT(DefaultsTestTable, id) +SPACETIMEDB_TABLE(DefaultsTestTable, defaults_test_table, Public) diff --git a/crates/smoketests/modules/cpp/column-defaults-cpp-updated.cpp b/crates/smoketests/modules/cpp/column-defaults-cpp-updated.cpp new file mode 100644 index 00000000000..b45c875b8c0 --- /dev/null +++ b/crates/smoketests/modules/cpp/column-defaults-cpp-updated.cpp @@ -0,0 +1,55 @@ + +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +struct DefaultsTestTable { + uint32_t id; + bool bool_value; + int8_t i8_value; + uint8_t u8_value; + int16_t i16_value; + uint16_t u16_value; + int32_t i32_value; + uint32_t u32_value; + int64_t i64_value; + uint64_t u64_value; + float f32_positive_value; + float f32_negative_value; + double f64_positive_value; + double f64_negative_value; + std::string string_value; +}; +SPACETIMEDB_STRUCT( + DefaultsTestTable, + id, + bool_value, + i8_value, + u8_value, + i16_value, + u16_value, + i32_value, + u32_value, + i64_value, + u64_value, + f32_positive_value, + f32_negative_value, + f64_positive_value, + f64_negative_value, + string_value +) +SPACETIMEDB_TABLE(DefaultsTestTable, defaults_test_table, Public) +FIELD_Default(defaults_test_table, bool_value, true) +FIELD_Default(defaults_test_table, i8_value, int8_t(-8)) +FIELD_Default(defaults_test_table, u8_value, uint8_t(8)) +FIELD_Default(defaults_test_table, i16_value, int16_t(-16)) +FIELD_Default(defaults_test_table, u16_value, uint16_t(16)) +FIELD_Default(defaults_test_table, i32_value, int32_t(-32)) +FIELD_Default(defaults_test_table, u32_value, uint32_t(32)) +FIELD_Default(defaults_test_table, i64_value, int64_t(-64)) +FIELD_Default(defaults_test_table, u64_value, uint64_t(64)) +FIELD_Default(defaults_test_table, f32_positive_value, float(32.5)) +FIELD_Default(defaults_test_table, f32_negative_value, float(-32.5)) +FIELD_Default(defaults_test_table, f64_positive_value, double(64.25)) +FIELD_Default(defaults_test_table, f64_negative_value, double(-64.25)) +FIELD_Default(defaults_test_table, string_value, std::string("default string")) diff --git a/crates/smoketests/modules/cpp/http-routes-cpp-basic.cpp b/crates/smoketests/modules/cpp/http-routes-cpp-basic.cpp new file mode 100644 index 00000000000..4898219ac9b --- /dev/null +++ b/crates/smoketests/modules/cpp/http-routes-cpp-basic.cpp @@ -0,0 +1,88 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +struct Entry { + uint64_t id; + std::string value; +}; +SPACETIMEDB_STRUCT(Entry, id, value) +SPACETIMEDB_TABLE(Entry, entry, Public) + +namespace { + +std::string header_value_utf8(const HttpRequest& request, const std::string& header_name) { + for (const auto& header : request.headers) { + if (header.name == header_name) { + return std::string(header.value.begin(), header.value.end()); + } + } + return ""; +} + +HttpResponse text_response(uint16_t status_code, std::string body) { + return HttpResponse{ + status_code, + HttpVersion::Http11, + { HttpHeader{"content-type", "text/plain; charset=utf-8"} }, + HttpBody::from_string(body), + }; +} + +} // namespace + +SPACETIMEDB_HTTP_HANDLER(get_simple, HandlerContext ctx, HttpRequest request) { + return text_response(200, "ok"); +} + +SPACETIMEDB_HTTP_HANDLER(post_insert, HandlerContext ctx, HttpRequest request) { + ctx.with_tx([](TxContext& tx) { + uint64_t id = tx.db[entry].count(); + tx.db[entry].insert(Entry{ id, "posted" }); + }); + return text_response(200, "inserted"); +} + +SPACETIMEDB_HTTP_HANDLER(get_count, HandlerContext ctx, HttpRequest request) { + uint64_t count = ctx.with_tx([](TxContext& tx) -> uint64_t { + return tx.db[entry].count(); + }); + return text_response(200, std::to_string(count)); +} + +SPACETIMEDB_HTTP_HANDLER(any_handler, HandlerContext ctx, HttpRequest request) { + return text_response(200, "any"); +} + +SPACETIMEDB_HTTP_HANDLER(header_echo, HandlerContext ctx, HttpRequest request) { + return text_response(200, header_value_utf8(request, "x-echo")); +} + +SPACETIMEDB_HTTP_HANDLER(set_response_header, HandlerContext ctx, HttpRequest request) { + return HttpResponse{ + 200, + HttpVersion::Http11, + { HttpHeader{"x-response", "set"} }, + HttpBody::from_string("header-set"), + }; +} + +SPACETIMEDB_HTTP_HANDLER(body_handler, HandlerContext ctx, HttpRequest request) { + return text_response(200, "non-empty"); +} + +SPACETIMEDB_HTTP_HANDLER(teapot, HandlerContext ctx, HttpRequest request) { + return text_response(418, "teapot"); +} + +SPACETIMEDB_HTTP_ROUTER(router) { + return Router() + .get("/get", get_simple) + .post("/post", post_insert) + .get("/count", get_count) + .any("/any", any_handler) + .get("/header", header_echo) + .get("/set-header", set_response_header) + .get("/body", body_handler) + .get("/teapot", teapot); +} diff --git a/crates/smoketests/modules/cpp/http-routes-cpp-example.cpp b/crates/smoketests/modules/cpp/http-routes-cpp-example.cpp new file mode 100644 index 00000000000..e2860a0d0f1 --- /dev/null +++ b/crates/smoketests/modules/cpp/http-routes-cpp-example.cpp @@ -0,0 +1,95 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +struct Data { + uint64_t id; + std::vector body; +}; +SPACETIMEDB_STRUCT(Data, id, body) +SPACETIMEDB_TABLE(Data, data, Public) +FIELD_PrimaryKeyAutoInc(data, id) + +namespace { + +HttpResponse bytes_response(uint16_t status_code, std::vector body) { + return HttpResponse{ + status_code, + HttpVersion::Http11, + {}, + HttpBody{std::move(body)}, + }; +} + +HttpResponse text_response(uint16_t status_code, std::string body) { + return HttpResponse{ + status_code, + HttpVersion::Http11, + {}, + HttpBody::from_string(body), + }; +} + +std::string query_value(const std::string& uri, const std::string& key) { + std::string needle = "?" + key + "="; + size_t pos = uri.find(needle); + if (pos == std::string::npos) { + needle = "&" + key + "="; + pos = uri.find(needle); + } + if (pos == std::string::npos) { + return ""; + } + pos += needle.size(); + size_t end = uri.find('&', pos); + return uri.substr(pos, end == std::string::npos ? std::string::npos : end - pos); +} + +bool try_parse_u64(const std::string& text, uint64_t& value) { + if (text.empty()) { + return false; + } + uint64_t result = 0; + for (char c : text) { + if (c < '0' || c > '9') { + return false; + } + result = (result * 10) + static_cast(c - '0'); + } + value = result; + return true; +} + +} // namespace + +SPACETIMEDB_HTTP_HANDLER(insert, HandlerContext ctx, HttpRequest request) { + std::vector body = request.body.to_bytes(); + uint64_t id = ctx.with_tx([&](TxContext& tx) -> uint64_t { + return tx.db[data].insert(Data{0, body}).id; + }); + return text_response(200, std::to_string(id)); +} + +SPACETIMEDB_HTTP_HANDLER(retrieve, HandlerContext ctx, HttpRequest request) { + uint64_t id = 0; + if (!try_parse_u64(query_value(request.uri, "id"), id)) { + return text_response(500, "invalid id"); + } + + auto body = ctx.with_tx([&](TxContext& tx) -> std::optional> { + auto row = tx.db[data_id].find(id); + if (row.has_value()) { + return row->body; + } + return std::nullopt; + }); + + if (body.has_value()) { + return bytes_response(200, std::move(body.value())); + } + return bytes_response(404, {}); +} + +SPACETIMEDB_HTTP_ROUTER(router) { + return Router().post("/insert", insert).get("/retrieve", retrieve); +} diff --git a/crates/smoketests/modules/cpp/http-routes-cpp-full-uri.cpp b/crates/smoketests/modules/cpp/http-routes-cpp-full-uri.cpp new file mode 100644 index 00000000000..14999339c96 --- /dev/null +++ b/crates/smoketests/modules/cpp/http-routes-cpp-full-uri.cpp @@ -0,0 +1,16 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +SPACETIMEDB_HTTP_HANDLER(echo_uri, HandlerContext ctx, HttpRequest request) { + return HttpResponse{ + 200, + HttpVersion::Http11, + {}, + HttpBody::from_string(request.uri), + }; +} + +SPACETIMEDB_HTTP_ROUTER(router) { + return Router().get("/echo-uri", echo_uri); +} diff --git a/crates/smoketests/modules/cpp/http-routes-cpp-request-body.cpp b/crates/smoketests/modules/cpp/http-routes-cpp-request-body.cpp new file mode 100644 index 00000000000..1058ec92dc8 --- /dev/null +++ b/crates/smoketests/modules/cpp/http-routes-cpp-request-body.cpp @@ -0,0 +1,58 @@ +#include "spacetimedb.h" +#include + +using namespace SpacetimeDB; + +namespace { + +HttpResponse bytes_response(uint16_t status_code, std::vector body) { + return HttpResponse{status_code, HttpVersion::Http11, {}, HttpBody{std::move(body)}}; +} + +HttpResponse text_response(uint16_t status_code, const std::string& body) { + return HttpResponse{status_code, HttpVersion::Http11, {}, HttpBody::from_string(body)}; +} + +} // namespace + +SPACETIMEDB_HTTP_HANDLER(reverse_bytes, HandlerContext ctx, HttpRequest request) { + std::vector reversed = request.body.to_bytes(); + std::reverse(reversed.begin(), reversed.end()); + return bytes_response(200, std::move(reversed)); +} + +SPACETIMEDB_HTTP_HANDLER(reverse_words, HandlerContext ctx, HttpRequest request) { + const std::vector bytes = request.body.to_bytes(); + std::string body(bytes.begin(), bytes.end()); + if (body.find(static_cast(0x80)) != std::string::npos) { + return text_response(400, "request body must be valid UTF-8"); + } + + std::vector words; + size_t start = 0; + while (true) { + size_t pos = body.find(' ', start); + words.push_back(body.substr(start, pos == std::string::npos ? std::string::npos : pos - start)); + if (pos == std::string::npos) { + break; + } + start = pos + 1; + } + std::reverse(words.begin(), words.end()); + + std::string reversed; + for (size_t i = 0; i < words.size(); ++i) { + if (i != 0) { + reversed += " "; + } + reversed += words[i]; + } + + return text_response(200, reversed); +} + +SPACETIMEDB_HTTP_ROUTER(router) { + return Router() + .post("/reverse-bytes", reverse_bytes) + .post("/reverse-words", reverse_words); +} diff --git a/crates/smoketests/modules/cpp/http-routes-cpp-strict-non-root.cpp b/crates/smoketests/modules/cpp/http-routes-cpp-strict-non-root.cpp new file mode 100644 index 00000000000..ccf7d817918 --- /dev/null +++ b/crates/smoketests/modules/cpp/http-routes-cpp-strict-non-root.cpp @@ -0,0 +1,25 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +namespace { + +HttpResponse text_response(const std::string& body) { + return HttpResponse{200, HttpVersion::Http11, {}, HttpBody::from_string(body)}; +} + +} // namespace + +SPACETIMEDB_HTTP_HANDLER(foo, HandlerContext ctx, HttpRequest request) { + return text_response("foo"); +} + +SPACETIMEDB_HTTP_HANDLER(foo_slash, HandlerContext ctx, HttpRequest request) { + return text_response("foo-slash"); +} + +SPACETIMEDB_HTTP_ROUTER(router) { + return Router() + .get("/foo", foo) + .get("/foo/", foo_slash); +} diff --git a/crates/smoketests/modules/cpp/http-routes-cpp-strict-root.cpp b/crates/smoketests/modules/cpp/http-routes-cpp-strict-root.cpp new file mode 100644 index 00000000000..541fedf2229 --- /dev/null +++ b/crates/smoketests/modules/cpp/http-routes-cpp-strict-root.cpp @@ -0,0 +1,35 @@ +#include "spacetimedb.h" + +using namespace SpacetimeDB; + +namespace { + +HttpResponse text_response(const std::string& body) { + return HttpResponse{200, HttpVersion::Http11, {}, HttpBody::from_string(body)}; +} + +} // namespace + +SPACETIMEDB_HTTP_HANDLER(empty_root, HandlerContext ctx, HttpRequest request) { + return text_response("empty"); +} + +SPACETIMEDB_HTTP_HANDLER(slash_root, HandlerContext ctx, HttpRequest request) { + return text_response("slash"); +} + +SPACETIMEDB_HTTP_HANDLER(foo, HandlerContext ctx, HttpRequest request) { + return text_response("foo"); +} + +SPACETIMEDB_HTTP_HANDLER(foo_slash, HandlerContext ctx, HttpRequest request) { + return text_response("foo-slash"); +} + +SPACETIMEDB_HTTP_ROUTER(router) { + return Router() + .get("", empty_root) + .get("/", slash_root) + .get("/foo", foo) + .get("/foo/", foo_slash); +} diff --git a/crates/smoketests/modules/csharp/column-defaults-csharp-initial.cs b/crates/smoketests/modules/csharp/column-defaults-csharp-initial.cs new file mode 100644 index 00000000000..37e0cf82f25 --- /dev/null +++ b/crates/smoketests/modules/csharp/column-defaults-csharp-initial.cs @@ -0,0 +1,11 @@ + +using SpacetimeDB; + +public static partial class Module +{ + [Table(Accessor = "defaults_test_table", Public = true)] + public partial struct DefaultsTestTable + { + public uint id; + } +} diff --git a/crates/smoketests/modules/csharp/column-defaults-csharp-updated.cs b/crates/smoketests/modules/csharp/column-defaults-csharp-updated.cs new file mode 100644 index 00000000000..c2279b465d0 --- /dev/null +++ b/crates/smoketests/modules/csharp/column-defaults-csharp-updated.cs @@ -0,0 +1,25 @@ + +using SpacetimeDB; + +public static partial class Module +{ + [Table(Accessor = "defaults_test_table", Public = true)] + public partial struct DefaultsTestTable + { + public uint id; + [Default(true)] public bool bool_value; + [Default((sbyte)-8)] public sbyte i8_value; + [Default((byte)8)] public byte u8_value; + [Default((short)-16)] public short i16_value; + [Default((ushort)16)] public ushort u16_value; + [Default(-32)] public int i32_value; + [Default(32U)] public uint u32_value; + [Default(-64L)] public long i64_value; + [Default(64UL)] public ulong u64_value; + [Default(32.5f)] public float f32_positive_value; + [Default(-32.5f)] public float f32_negative_value; + [Default(64.25)] public double f64_positive_value; + [Default(-64.25)] public double f64_negative_value; + [Default("default string")] public string string_value; + } +} diff --git a/crates/smoketests/modules/csharp/http-routes-csharp-basic.cs b/crates/smoketests/modules/csharp/http-routes-csharp-basic.cs new file mode 100644 index 00000000000..550f3b2d0cb --- /dev/null +++ b/crates/smoketests/modules/csharp/http-routes-csharp-basic.cs @@ -0,0 +1,111 @@ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE +public static partial class Module +{ + [SpacetimeDB.Table(Accessor = "Entry", Name = "entry", Public = true)] + public partial struct Entry + { + [SpacetimeDB.PrimaryKey] + public ulong Id; + + public string Value; + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse GetSimple(HandlerContext ctx, HttpRequest request) + { + return TextResponse(200, "ok"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse PostInsert(HandlerContext ctx, HttpRequest request) + { + ctx.WithTx((HandlerTxContext tx) => + { + var id = tx.Db.Entry.Count; + tx.Db.Entry.Insert(new Entry { Id = id, Value = "posted" }); + return 0; + }); + return TextResponse(200, "inserted"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse GetCount(HandlerContext ctx, HttpRequest request) + { + var count = ctx.WithTx((HandlerTxContext tx) => tx.Db.Entry.Count); + return TextResponse(200, count.ToString()); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse AnyHandler(HandlerContext ctx, HttpRequest request) + { + return TextResponse(200, "any"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse HeaderEcho(HandlerContext ctx, HttpRequest request) + { + return TextResponse(200, HeaderValueUtf8(request, "x-echo")); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse SetResponseHeader(HandlerContext ctx, HttpRequest request) + { + return new HttpResponse( + 200, + HttpVersion.Http11, + new List { new("x-response", "set") }, + HttpBody.FromString("header-set") + ); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse BodyHandler(HandlerContext ctx, HttpRequest request) + { + return TextResponse(200, "non-empty"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse Teapot(HandlerContext ctx, HttpRequest request) + { + return TextResponse(418, "teapot"); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Get("/get", Handlers.GetSimple) + .Post("/post", Handlers.PostInsert) + .Get("/count", Handlers.GetCount) + .Any("/any", Handlers.AnyHandler) + .Get("/header", Handlers.HeaderEcho) + .Get("/set-header", Handlers.SetResponseHeader) + .Get("/body", Handlers.BodyHandler) + .Get("/teapot", Handlers.Teapot); + + private static string HeaderValueUtf8(HttpRequest request, string headerName) + { + foreach (var header in request.Headers) + { + if (string.Equals(header.Name, headerName, StringComparison.OrdinalIgnoreCase)) + { + return Encoding.UTF8.GetString(header.Value); + } + } + return string.Empty; + } + + private static HttpResponse TextResponse(ushort statusCode, string body) => + new( + statusCode, + HttpVersion.Http11, + new List(), + HttpBody.FromString(body) + ); +} diff --git a/crates/smoketests/modules/csharp/http-routes-csharp-example.cs b/crates/smoketests/modules/csharp/http-routes-csharp-example.cs new file mode 100644 index 00000000000..89fd33347a1 --- /dev/null +++ b/crates/smoketests/modules/csharp/http-routes-csharp-example.cs @@ -0,0 +1,52 @@ + +using System.Collections.Generic; +using SpacetimeDB; + +#pragma warning disable STDB_UNSTABLE +public static partial class Module +{ + [SpacetimeDB.Table(Accessor = "Data", Name = "data", Public = true)] + public partial struct Data + { + [SpacetimeDB.PrimaryKey] + [SpacetimeDB.AutoInc] + public ulong Id; + + public byte[] Body; + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse Insert(HandlerContext ctx, HttpRequest request) + { + var body = request.Body.ToBytes(); + var id = ctx.WithTx((HandlerTxContext tx) => tx.Db.Data.Insert(new Data { Id = 0, Body = body }).Id); + return TextResponse(200, id.ToString()); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse Retrieve(HandlerContext ctx, HttpRequest request) + { + var idText = request.Uri.Split("id=", 2)[1]; + var id = ulong.Parse(idText); + var body = ctx.WithTx((HandlerTxContext tx) => tx.Db.Data.Id.Find(id)?.Body); + + if (body is not null) + { + return BytesResponse(200, body); + } + + return new HttpResponse(404, HttpVersion.Http11, new List(), HttpBody.Empty); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Post("/insert", Handlers.Insert) + .Get("/retrieve", Handlers.Retrieve); + + private static HttpResponse BytesResponse(ushort statusCode, byte[] body) => + new(statusCode, HttpVersion.Http11, new List(), new HttpBody(body)); + + private static HttpResponse TextResponse(ushort statusCode, string body) => + new(statusCode, HttpVersion.Http11, new List(), HttpBody.FromString(body)); +} diff --git a/crates/smoketests/modules/csharp/http-routes-csharp-full-uri.cs b/crates/smoketests/modules/csharp/http-routes-csharp-full-uri.cs new file mode 100644 index 00000000000..08458ee0cb7 --- /dev/null +++ b/crates/smoketests/modules/csharp/http-routes-csharp-full-uri.cs @@ -0,0 +1,21 @@ + +using System.Collections.Generic; +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.HttpHandler] + public static HttpResponse EchoUri(HandlerContext ctx, HttpRequest request) + { + return new HttpResponse( + 200, + HttpVersion.Http11, + new List(), + HttpBody.FromString(request.Uri) + ); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New().Get("/echo-uri", Handlers.EchoUri); +} diff --git a/crates/smoketests/modules/csharp/http-routes-csharp-request-body.cs b/crates/smoketests/modules/csharp/http-routes-csharp-request-body.cs new file mode 100644 index 00000000000..accaf6cfa5c --- /dev/null +++ b/crates/smoketests/modules/csharp/http-routes-csharp-request-body.cs @@ -0,0 +1,45 @@ + +using System; +using System.Collections.Generic; +using System.Text; +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.HttpHandler] + public static HttpResponse ReverseBytes(HandlerContext ctx, HttpRequest request) + { + var reversed = request.Body.ToBytes(); + Array.Reverse(reversed); + return BytesResponse(200, reversed); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse ReverseWords(HandlerContext ctx, HttpRequest request) + { + string body; + try + { + body = new UTF8Encoding(false, true).GetString(request.Body.ToBytes()); + } + catch (DecoderFallbackException) + { + return TextResponse(400, "request body must be valid UTF-8"); + } + + var reversed = string.Join(" ", body.Split(' ').Reverse()); + return TextResponse(200, reversed); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Post("/reverse-bytes", Handlers.ReverseBytes) + .Post("/reverse-words", Handlers.ReverseWords); + + private static HttpResponse BytesResponse(ushort statusCode, byte[] body) => + new(statusCode, HttpVersion.Http11, new List(), new HttpBody(body)); + + private static HttpResponse TextResponse(ushort statusCode, string body) => + new(statusCode, HttpVersion.Http11, new List(), HttpBody.FromString(body)); +} diff --git a/crates/smoketests/modules/csharp/http-routes-csharp-strict-non-root.cs b/crates/smoketests/modules/csharp/http-routes-csharp-strict-non-root.cs new file mode 100644 index 00000000000..979fbb83a67 --- /dev/null +++ b/crates/smoketests/modules/csharp/http-routes-csharp-strict-non-root.cs @@ -0,0 +1,27 @@ + +using System.Collections.Generic; +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.HttpHandler] + public static HttpResponse Foo(HandlerContext ctx, HttpRequest request) + { + return TextResponse("foo"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse FooSlash(HandlerContext ctx, HttpRequest request) + { + return TextResponse("foo-slash"); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Get("/foo", Handlers.Foo) + .Get("/foo/", Handlers.FooSlash); + + private static HttpResponse TextResponse(string body) => + new(200, HttpVersion.Http11, new List(), HttpBody.FromString(body)); +} diff --git a/crates/smoketests/modules/csharp/http-routes-csharp-strict-root.cs b/crates/smoketests/modules/csharp/http-routes-csharp-strict-root.cs new file mode 100644 index 00000000000..5a49bf1b4e7 --- /dev/null +++ b/crates/smoketests/modules/csharp/http-routes-csharp-strict-root.cs @@ -0,0 +1,41 @@ + +using System.Collections.Generic; +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.HttpHandler] + public static HttpResponse EmptyRoot(HandlerContext ctx, HttpRequest request) + { + return TextResponse("empty"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse SlashRoot(HandlerContext ctx, HttpRequest request) + { + return TextResponse("slash"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse Foo(HandlerContext ctx, HttpRequest request) + { + return TextResponse("foo"); + } + + [SpacetimeDB.HttpHandler] + public static HttpResponse FooSlash(HandlerContext ctx, HttpRequest request) + { + return TextResponse("foo-slash"); + } + + [SpacetimeDB.HttpRouter] + public static Router Router() => + SpacetimeDB.Router.New() + .Get("", Handlers.EmptyRoot) + .Get("/", Handlers.SlashRoot) + .Get("/foo", Handlers.Foo) + .Get("/foo/", Handlers.FooSlash); + + private static HttpResponse TextResponse(string body) => + new(200, HttpVersion.Http11, new List(), HttpBody.FromString(body)); +} diff --git a/crates/smoketests/modules/csharp/views-count-csharp.cs b/crates/smoketests/modules/csharp/views-count-csharp.cs new file mode 100644 index 00000000000..6e6228ca0e5 --- /dev/null +++ b/crates/smoketests/modules/csharp/views-count-csharp.cs @@ -0,0 +1,49 @@ +using SpacetimeDB; + +[SpacetimeDB.Type] +public partial struct ItemCount +{ + public ulong count; +} + +public static partial class Module +{ + [Table(Accessor = "item", Public = true)] + public partial struct Item + { + [PrimaryKey] + public uint id; + public uint value; + } + + [View(Accessor = "sender_table_count", Public = true)] + public static ItemCount? sender_table_count(ViewContext ctx) + { + return new ItemCount { count = ctx.Db.item.Count }; + } + + [View(Accessor = "anon_table_count", Public = true)] + public static ItemCount? anon_table_count(AnonymousViewContext ctx) + { + return new ItemCount { count = ctx.Db.item.Count }; + } + + [Reducer] + public static void insert_item(ReducerContext ctx, uint id, uint value) + { + ctx.Db.item.Insert(new Item { id = id, value = value }); + } + + [Reducer] + public static void replace_item(ReducerContext ctx, uint id, uint value) + { + ctx.Db.item.id.Delete(id); + ctx.Db.item.Insert(new Item { id = id, value = value }); + } + + [Reducer] + public static void delete_item(ReducerContext ctx, uint id) + { + ctx.Db.item.id.Delete(id); + } +} diff --git a/crates/smoketests/modules/csharp/views-csharp.cs b/crates/smoketests/modules/csharp/views-csharp.cs new file mode 100644 index 00000000000..2c5e9ca8533 --- /dev/null +++ b/crates/smoketests/modules/csharp/views-csharp.cs @@ -0,0 +1,29 @@ +using SpacetimeDB; + +public static partial class Module +{ + [Table(Accessor = "Table", Public = true)] + public partial struct Table + { + public uint Value; + public bool Alive; + } + + [Reducer] + public static void InsertValue(ReducerContext ctx, uint value, bool alive) + { + ctx.Db.Table.Insert(new Table { Value = value, Alive = alive }); + } + + [View(Accessor = "all", Public = true)] + public static IQuery All(ViewContext ctx) + { + return ctx.From.Table(); + } + + [View(Accessor = "some", Public = true)] + public static IQuery
Some(ViewContext ctx) + { + return ctx.From.Table().Where(Row => Row.Alive); + } +} diff --git a/crates/smoketests/modules/typescript/column-defaults-ts-initial.ts b/crates/smoketests/modules/typescript/column-defaults-ts-initial.ts new file mode 100644 index 00000000000..7c9629d5d18 --- /dev/null +++ b/crates/smoketests/modules/typescript/column-defaults-ts-initial.ts @@ -0,0 +1,9 @@ + +import { schema, t, table } from "spacetimedb/server"; + +const defaultsTestTable = table( + { name: "defaults_test_table", public: true }, + { id: t.u32() } +); + +export default schema({ defaultsTestTable }); diff --git a/crates/smoketests/modules/typescript/column-defaults-ts-updated.ts b/crates/smoketests/modules/typescript/column-defaults-ts-updated.ts new file mode 100644 index 00000000000..ddcf755ea77 --- /dev/null +++ b/crates/smoketests/modules/typescript/column-defaults-ts-updated.ts @@ -0,0 +1,25 @@ + +import { schema, t, table } from "spacetimedb/server"; + +const defaultsTestTable = table( + { name: "defaults_test_table", public: true }, + { + id: t.u32(), + bool_value: t.bool().default(true), + i8_value: t.i8().default(-8), + u8_value: t.u8().default(8), + i16_value: t.i16().default(-16), + u16_value: t.u16().default(16), + i32_value: t.i32().default(-32), + u32_value: t.u32().default(32), + i64_value: t.i64().default(-64n), + u64_value: t.u64().default(64n), + f32_positive_value: t.f32().default(32.5), + f32_negative_value: t.f32().default(-32.5), + f64_positive_value: t.f64().default(64.25), + f64_negative_value: t.f64().default(-64.25), + string_value: t.string().default("default string"), + } +); + +export default schema({ defaultsTestTable }); diff --git a/crates/smoketests/modules/typescript/http-routes-typescript-basic.ts b/crates/smoketests/modules/typescript/http-routes-typescript-basic.ts new file mode 100644 index 00000000000..4569a8e5f31 --- /dev/null +++ b/crates/smoketests/modules/typescript/http-routes-typescript-basic.ts @@ -0,0 +1,61 @@ +import { Router, SyncResponse, schema, table, t } from "spacetimedb/server"; + +const entry = table( + { name: "entry", public: true }, + { + id: t.u64().primaryKey(), + value: t.string(), + } +); + +const spacetimedb = schema({ entry }); +export default spacetimedb; + +export const get_simple = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("ok") +); + +export const post_insert = spacetimedb.httpHandler((ctx, _req) => { + ctx.withTx(tx => { + const id = BigInt(tx.db.entry.count()); + tx.db.entry.insert({ id, value: "posted" }); + }); + return new SyncResponse("inserted"); +}); + +export const get_count = spacetimedb.httpHandler((ctx, _req) => { + const count = ctx.withTx(tx => tx.db.entry.count()); + return new SyncResponse(String(count)); +}); + +export const any_handler = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("any") +); + +export const header_echo = spacetimedb.httpHandler((_ctx, req) => + new SyncResponse(req.headers.get("x-echo") ?? "") +); + +export const set_response_header = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("header-set", { headers: { "x-response": "set" } }) +); + +export const body_handler = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("non-empty") +); + +export const teapot = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("teapot", { status: 418 }) +); + +export const router = spacetimedb.httpRouter( + new Router() + .get("/get", get_simple) + .post("/post", post_insert) + .get("/count", get_count) + .any("/any", any_handler) + .get("/header", header_echo) + .get("/set-header", set_response_header) + .get("/body", body_handler) + .get("/teapot", teapot) +); diff --git a/crates/smoketests/modules/typescript/http-routes-typescript-example.ts b/crates/smoketests/modules/typescript/http-routes-typescript-example.ts new file mode 100644 index 00000000000..146cd435db5 --- /dev/null +++ b/crates/smoketests/modules/typescript/http-routes-typescript-example.ts @@ -0,0 +1,33 @@ +import { Router, SyncResponse, schema, table, t } from "spacetimedb/server"; + +const data = table( + { name: "data" }, + { + id: t.u64().primaryKey().autoInc(), + body: t.array(t.u8()), + } +); + +const spacetimedb = schema({ data }); +export default spacetimedb; + +export const insert = spacetimedb.httpHandler((ctx, req) => { + const body = Array.from(req.bytes()); + const id = ctx.withTx(tx => tx.db.data.insert({ id: 0n, body }).id); + return new SyncResponse(String(id)); +}); + +export const retrieve = spacetimedb.httpHandler((ctx, req) => { + const query = req.uri.split("?", 2)[1] ?? ""; + const idText = query.startsWith("id=") ? query.slice(3) : ""; + const id = BigInt(idText); + const body = ctx.withTx(tx => tx.db.data.id.find(id)?.body); + if (body != null) { + return new SyncResponse(new Uint8Array(body)); + } + return new SyncResponse(null, { status: 404 }); +}); + +export const router = spacetimedb.httpRouter( + new Router().post("/insert", insert).get("/retrieve", retrieve) +); diff --git a/crates/smoketests/modules/typescript/http-routes-typescript-full-uri.ts b/crates/smoketests/modules/typescript/http-routes-typescript-full-uri.ts new file mode 100644 index 00000000000..14b53eaeacd --- /dev/null +++ b/crates/smoketests/modules/typescript/http-routes-typescript-full-uri.ts @@ -0,0 +1,12 @@ +import { Router, SyncResponse, schema } from "spacetimedb/server"; + +const spacetimedb = schema({}); +export default spacetimedb; + +export const echo_uri = spacetimedb.httpHandler((_ctx, req) => + new SyncResponse(req.uri) +); + +export const router = spacetimedb.httpRouter( + new Router().get("/echo-uri", echo_uri) +); diff --git a/crates/smoketests/modules/typescript/http-routes-typescript-request-body.ts b/crates/smoketests/modules/typescript/http-routes-typescript-request-body.ts new file mode 100644 index 00000000000..28671618234 --- /dev/null +++ b/crates/smoketests/modules/typescript/http-routes-typescript-request-body.ts @@ -0,0 +1,28 @@ +import { Router, SyncResponse, schema } from "spacetimedb/server"; + +const spacetimedb = schema({}); +export default spacetimedb; + +export const reverse_bytes = spacetimedb.httpHandler((_ctx, req) => { + const reversed = req.bytes(); + reversed.reverse(); + return new SyncResponse(reversed); +}); + +export const reverse_words = spacetimedb.httpHandler((_ctx, req) => { + let body; + try { + body = new TextDecoder("utf-8", { fatal: true }).decode(req.bytes()); + } catch { + return new SyncResponse("request body must be valid UTF-8", { status: 400 }); + } + + const reversed = body.split(" ").reverse().join(" "); + return new SyncResponse(reversed); +}); + +export const router = spacetimedb.httpRouter( + new Router() + .post("/reverse-bytes", reverse_bytes) + .post("/reverse-words", reverse_words) +); diff --git a/crates/smoketests/modules/typescript/http-routes-typescript-strict-non-root.ts b/crates/smoketests/modules/typescript/http-routes-typescript-strict-non-root.ts new file mode 100644 index 00000000000..7d455e0cba7 --- /dev/null +++ b/crates/smoketests/modules/typescript/http-routes-typescript-strict-non-root.ts @@ -0,0 +1,18 @@ +import { Router, SyncResponse, schema } from "spacetimedb/server"; + +const spacetimedb = schema({}); +export default spacetimedb; + +export const foo = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("foo") +); + +export const foo_slash = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("foo-slash") +); + +export const router = spacetimedb.httpRouter( + new Router() + .get("/foo", foo) + .get("/foo/", foo_slash) +); diff --git a/crates/smoketests/modules/typescript/http-routes-typescript-strict-root.ts b/crates/smoketests/modules/typescript/http-routes-typescript-strict-root.ts new file mode 100644 index 00000000000..2f5e76f531e --- /dev/null +++ b/crates/smoketests/modules/typescript/http-routes-typescript-strict-root.ts @@ -0,0 +1,28 @@ +import { Router, SyncResponse, schema } from "spacetimedb/server"; + +const spacetimedb = schema({}); +export default spacetimedb; + +export const empty_root = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("empty") +); + +export const slash_root = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("slash") +); + +export const foo = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("foo") +); + +export const foo_slash = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("foo-slash") +); + +export const router = spacetimedb.httpRouter( + new Router() + .get("", empty_root) + .get("/", slash_root) + .get("/foo", foo) + .get("/foo/", foo_slash) +); diff --git a/crates/smoketests/modules/typescript/modules-basic-ts.ts b/crates/smoketests/modules/typescript/modules-basic-ts.ts new file mode 100644 index 00000000000..08d61a1c263 --- /dev/null +++ b/crates/smoketests/modules/typescript/modules-basic-ts.ts @@ -0,0 +1,15 @@ +import { schema, t, table } from "spacetimedb/server"; + +const person = table( + { name: "person", public: true }, + { + id: t.u64().primaryKey().autoInc(), + name: t.string() + } +); +const spacetimedb = schema({ person }); +export default spacetimedb; + +export const add = spacetimedb.reducer({ name: t.string() }, (ctx, { name }) => { + ctx.db.person.insert({ id: 0n, name }); +}); diff --git a/crates/smoketests/modules/typescript/typescript-add-optional-columns-v1.ts b/crates/smoketests/modules/typescript/typescript-add-optional-columns-v1.ts new file mode 100644 index 00000000000..047e089121f --- /dev/null +++ b/crates/smoketests/modules/typescript/typescript-add-optional-columns-v1.ts @@ -0,0 +1,29 @@ +import { schema, table, t } from "spacetimedb/server"; + +const AppUsers = table( + { name: "users", public: false }, + { + id: t.u64().primaryKey().autoInc(), + name: t.string(), + emailAddress: t.string().index("btree"), + }, +); + +const spacetimedb = schema({ + AppUsers, +}); +export default spacetimedb; + +export const insert_user = spacetimedb.reducer( + { + name: t.string(), + emailAddress: t.string(), + }, + (ctx, { name, emailAddress }) => { + ctx.db.AppUsers.insert({ + id: 0n, + name, + emailAddress, + }); + }, +); diff --git a/crates/smoketests/modules/typescript/typescript-add-optional-columns-v2.ts b/crates/smoketests/modules/typescript/typescript-add-optional-columns-v2.ts new file mode 100644 index 00000000000..333beec61b2 --- /dev/null +++ b/crates/smoketests/modules/typescript/typescript-add-optional-columns-v2.ts @@ -0,0 +1,39 @@ +import { schema, table, t } from "spacetimedb/server"; + +const AppUsers = table( + { name: "users", public: false }, + { + id: t.u64().primaryKey().autoInc(), + name: t.string(), + emailAddress: t.string().index("btree"), + age: t.number().optional().default(undefined), + isActive: t.bool().default(false).index(), + }, +); + +const spacetimedb = schema({ + AppUsers, +}); +export default spacetimedb; + +export const find_user_by_email = spacetimedb.reducer( + { emailAddress: t.string() }, + (ctx, { emailAddress }) => { + let count = 0; + for (const _row of ctx.db.AppUsers.emailAddress.filter(emailAddress)) { + count += 1; + } + console.info(`matched ${count}`); + }, +); + +export const find_users_by_active_status = spacetimedb.reducer( + { isActive: t.bool() }, + (ctx, { isActive }) => { + let count = 0; + for (const _row of ctx.db.AppUsers.isActive.filter(isActive)) { + count += 1; + } + console.info(`matched active users ${count}`); + }, +); diff --git a/crates/smoketests/modules/typescript/typescript-change-source-name-v2.ts b/crates/smoketests/modules/typescript/typescript-change-source-name-v2.ts new file mode 100644 index 00000000000..b6e260e27e1 --- /dev/null +++ b/crates/smoketests/modules/typescript/typescript-change-source-name-v2.ts @@ -0,0 +1,26 @@ +import { schema, table, t } from "spacetimedb/server"; + +const renamedUsers = table( + { name: "users", public: false }, + { + id: t.u64().primaryKey().autoInc(), + name: t.string(), + emailAddress: t.string().index("btree"), + }, +); + +const spacetimedb = schema({ + renamedUsers, +}); +export default spacetimedb; + +export const find_user_by_email = spacetimedb.reducer( + { emailAddress: t.string() }, + (ctx, { emailAddress }) => { + let count = 0; + for (const _row of ctx.db.renamedUsers.emailAddress.filter(emailAddress)) { + count += 1; + } + console.info(`matched ${count}`); + }, +); diff --git a/crates/smoketests/modules/typescript/views-count-typescript.ts b/crates/smoketests/modules/typescript/views-count-typescript.ts new file mode 100644 index 00000000000..585b61ee502 --- /dev/null +++ b/crates/smoketests/modules/typescript/views-count-typescript.ts @@ -0,0 +1,50 @@ +import { schema, t, table } from "spacetimedb/server"; + +const item = table( + { name: "item" }, + { + id: t.u32().primaryKey(), + value: t.u32(), + } +); + +const itemCount = t.object("ItemCountRow", { + count: t.u64(), +}); + +const spacetimedb = schema({ item }); +export default spacetimedb; + +export const sender_table_count = spacetimedb.view( + { public: true }, + t.option(itemCount), + ctx => ({ count: ctx.db.item.count() }) +); + +export const anon_table_count = spacetimedb.anonymousView( + { public: true }, + t.option(itemCount), + ctx => ({ count: ctx.db.item.count() }) +); + +export const insert_item = spacetimedb.reducer( + { id: t.u32(), value: t.u32() }, + (ctx, { id, value }) => { + ctx.db.item.insert({ id, value }); + } +); + +export const replace_item = spacetimedb.reducer( + { id: t.u32(), value: t.u32() }, + (ctx, { id, value }) => { + ctx.db.item.id.delete(id); + ctx.db.item.insert({ id, value }); + } +); + +export const delete_item = spacetimedb.reducer( + { id: t.u32() }, + (ctx, { id }) => { + ctx.db.item.id.delete(id); + } +); diff --git a/crates/smoketests/modules/typescript/views-subscribe-typescript.ts b/crates/smoketests/modules/typescript/views-subscribe-typescript.ts new file mode 100644 index 00000000000..1b9dee0623e --- /dev/null +++ b/crates/smoketests/modules/typescript/views-subscribe-typescript.ts @@ -0,0 +1,43 @@ +import { schema, t, table } from "spacetimedb/server"; + +const playerState = table( + { name: "player_state" }, + { + identity: t.identity().primaryKey(), + name: t.string().unique(), + online: t.bool(), + } +); + +const spacetimedb = schema({ playerState }); +export default spacetimedb; + +export const my_player = spacetimedb.view( + { public: true }, + t.option(playerState.rowType), + ctx => ctx.db.playerState.identity.find(ctx.sender) ?? undefined +); + +export const all_players = spacetimedb.anonymousView( + { public: true }, + t.array(playerState.rowType), + ctx => ctx.from.playerState +); + +export const online_players = spacetimedb.anonymousView( + { public: true }, + t.array(playerState.rowType), + ctx => ctx.from.playerState.where(row => row.online) +); + +export const insert_player_proc = spacetimedb.procedure( + { name: t.string() }, + t.unit(), + (ctx, { name }) => { + const sender = ctx.sender; + ctx.withTx(tx => { + tx.db.playerState.insert({ name, identity: sender, online: true }); + }); + return {}; + } +); diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index ab6e8ce187d..0dea9dd7410 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -6,7 +6,7 @@ //! //! # Pre-compiled Modules //! -//! Rust modules are pre-compiled during the warmup phase. Use +//! Modules are pre-compiled during the warmup phase. Use //! `Smoketest::builder().precompiled_module("name")` to select a module from //! `crates/smoketests/modules/`. The default module is `noop`. //! @@ -37,6 +37,7 @@ mod csharp; pub mod modules; +pub mod prepare; mod template_registry; use anyhow::{bail, Context, Result}; @@ -436,55 +437,6 @@ pub fn have_emscripten() -> bool { *HAVE_EMSCRIPTEN.get_or_init(|| which("emcc").is_ok() || which("emcc.bat").is_ok()) } -const CPP_SMOKETEST_CMAKELISTS: &str = r#"cmake_minimum_required(VERSION 3.16) -project(smoketest_cpp_module) - -set(CMAKE_CXX_STANDARD 20) -set(CMAKE_CXX_STANDARD_REQUIRED ON) - -set(SPACETIMEDB_CPP_LIBRARY_PATH "@SPACETIMEDB_CPP_LIBRARY_PATH@") - -add_executable(lib src/lib.cpp) - -target_include_directories(lib PRIVATE - ${SPACETIMEDB_CPP_LIBRARY_PATH}/include -) - -if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") - target_compile_options(lib PRIVATE -fno-exceptions -O2 -g0) - target_compile_definitions(lib PRIVATE SPACETIMEDB_UNSTABLE_FEATURES) - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -DSPACETIMEDB_UNSTABLE_FEATURES") -endif() - -add_subdirectory(${SPACETIMEDB_CPP_LIBRARY_PATH} ${CMAKE_CURRENT_BINARY_DIR}/spacetimedb_cpp_library) -target_link_libraries(lib PRIVATE spacetimedb_cpp_library) - -if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") - set(EXPORTED_FUNCS - "['_malloc','_free','___describe_module__','___call_reducer__','___call_procedure__','___call_http_handler__']" - ) - - target_link_options(lib PRIVATE - "SHELL:-sSTANDALONE_WASM=1" - "SHELL:-sWASM=1" - "SHELL:--no-entry" - "SHELL:-sEXPORTED_FUNCTIONS=${EXPORTED_FUNCS}" - "SHELL:-sERROR_ON_UNDEFINED_SYMBOLS=1" - "SHELL:-sFILESYSTEM=0" - "SHELL:-sDISABLE_EXCEPTION_CATCHING=1" - "SHELL:-sALLOW_MEMORY_GROWTH=0" - "SHELL:-sINITIAL_MEMORY=16MB" - "SHELL:-sSUPPORT_LONGJMP=0" - "SHELL:-sSUPPORT_ERRNO=0" - "SHELL:-std=c++20" - "SHELL:-O2" - "SHELL:-g0" - ) - - set_target_properties(lib PROPERTIES OUTPUT_NAME "lib" SUFFIX ".wasm") -endif() -"#; - fn parse_identity_from_publish_output(publish_output: &str) -> Result { let re = Regex::new(r"identity: ([0-9a-fA-F]+)").unwrap(); re.captures(publish_output) @@ -508,8 +460,8 @@ pub struct Smoketest { pub server_url: String, /// Path to the test-specific CLI config file (isolates tests from user config). pub config_path: std::path::PathBuf, - /// Path to pre-compiled WASM file (if using precompiled_module). - precompiled_wasm_path: Option, + /// Selected precompiled WASM or JavaScript module. + precompiled_module: Option, /// Optional path to a specific CLI binary to run for this test. cli_path: Option, } @@ -548,20 +500,6 @@ pub struct PublishBuilder<'a> { organization: Option, force: Option<&'static str>, stdin_input: Option, - source: Option, -} - -#[derive(Clone, Copy, Debug)] -pub enum ModuleLanguage { - TypeScript, - CSharp, - Cpp, -} - -struct ModuleSource { - language: ModuleLanguage, - project_dir_name: String, - module_source: String, } impl<'a> PublishBuilder<'a> { @@ -575,7 +513,6 @@ impl<'a> PublishBuilder<'a> { organization: None, force: Some("all"), stdin_input: None, - source: None, } } @@ -626,20 +563,6 @@ impl<'a> PublishBuilder<'a> { Ok(self) } - pub fn source( - mut self, - language: ModuleLanguage, - project_dir_name: impl Into, - module_source: impl Into, - ) -> Self { - self.source = Some(ModuleSource { - language, - project_dir_name: project_dir_name.into(), - module_source: module_source.into(), - }); - self - } - pub fn run(self) -> Result { let start = Instant::now(); let PublishBuilder { @@ -651,70 +574,21 @@ impl<'a> PublishBuilder<'a> { organization, force, stdin_input, - source, } = self; - let post_publish_step: Option Result<()>>>; - let module_args; - if let Some(source) = source.as_ref() { - let module_name = name.as_deref().context("No module name provided for source publish")?; - match source.language { - ModuleLanguage::TypeScript => { - post_publish_step = None; - let module_path = smoketest.prepare_typescript_module_source_internal( - &source.project_dir_name, - module_name, - &source.module_source, - )?; - module_args = vec![ - "--module-path".to_string(), - module_path - .to_str() - .context("Invalid TypeScript module path")? - .to_string(), - ]; - } - ModuleLanguage::CSharp => { - let module_path = smoketest.prepare_csharp_module_source_internal( - &source.project_dir_name, - module_name, - &source.module_source, - )?; - let module_path_arg = module_path.to_str().context("Invalid C# module path")?.to_string(); - post_publish_step = Some(Box::new(move || csharp::verify_csharp_module_restore(&module_path))); - module_args = vec![ - "--module-path".to_string(), - module_path_arg, - "--dotnet-version".to_string(), - "10".to_string(), - ]; - } - ModuleLanguage::Cpp => { - post_publish_step = None; - let module_path = smoketest - .prepare_cpp_module_source_internal(&source.project_dir_name, &source.module_source)?; - module_args = vec![ - "--module-path".to_string(), - module_path.to_str().context("Invalid C++ module path")?.to_string(), - ]; - } - } - } else { - post_publish_step = None; - if smoketest.precompiled_wasm_path.is_none() { - smoketest.use_precompiled_module("noop"); - } - let module_path = smoketest.precompiled_wasm_path.as_ref().unwrap(); - // Use pre-compiled WASM directly (no build needed) - eprintln!("[TIMING] spacetime build: skipped (using precompiled)"); - module_args = vec![ - "--bin-path".to_string(), - module_path - .to_str() - .context("Invalid precompiled module path")? - .to_string(), - ]; + if smoketest.precompiled_module.is_none() { + smoketest.use_precompiled_module("noop"); } + let module = smoketest.precompiled_module.as_ref().unwrap(); + eprintln!("[TIMING] spacetime build: skipped (using precompiled)"); + let module_args = vec![ + module.publish_flag().to_string(), + module + .path() + .to_str() + .context("Invalid precompiled module path")? + .to_string(), + ]; let identity = smoketest.publish_module_internal( &module_args, @@ -727,10 +601,6 @@ impl<'a> PublishBuilder<'a> { stdin_input.as_deref(), )?; - if let Some(post_publish_step) = post_publish_step { - post_publish_step()?; - } - eprintln!("[TIMING] publish_module total: {:?}", start.elapsed()); Ok(identity) @@ -972,9 +842,9 @@ impl SmoketestBuilder { let project_dir = tempfile::tempdir().expect("Failed to create temp project directory"); // Check if we're using a pre-compiled module - let precompiled_wasm_path = self.precompiled_module.as_ref().map(|name| { + let precompiled_module = self.precompiled_module.as_ref().map(|name| { let path = modules::precompiled_module(name); - if !path.exists() { + if !path.path().exists() { panic!( "Pre-compiled module '{}' not found at {:?}. \ Run `cargo smoketest` to build pre-compiled modules during warmup.", @@ -997,7 +867,7 @@ impl SmoketestBuilder { database_identity: fixture_identity, server_url, config_path, - precompiled_wasm_path: precompiled_wasm_path.clone(), + precompiled_module: precompiled_module.clone(), cli_path: self.cli_path.clone(), }; @@ -1222,89 +1092,13 @@ impl Smoketest { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } - fn prepare_typescript_module_source_internal( - &mut self, - project_dir_name: &str, - module_name: &str, - module_source: &str, - ) -> Result { - let module_root = self.project_dir.path().join(project_dir_name); - let module_root_str = module_root.to_str().context("Invalid TypeScript project path")?; - self.spacetime(&[ - "init", - "--non-interactive", - "--lang", - "typescript", - "--project-path", - module_root_str, - module_name, - ])?; - - let module_path = module_root.join("spacetimedb"); - fs::write(module_path.join("src/index.ts"), module_source).context("Failed to write TypeScript module code")?; - - build_typescript_sdk()?; - let _ = pnpm(&["uninstall", "spacetimedb"], &module_path); - - let ts_bindings = workspace_root().join("crates/bindings-typescript"); - let ts_bindings_path = ts_bindings.to_str().context("Invalid TypeScript bindings path")?; - pnpm(&["install", ts_bindings_path], &module_path)?; - - Ok(module_path) - } - - fn prepare_csharp_module_source_internal( - &mut self, - project_dir_name: &str, - module_name: &str, - module_source: &str, - ) -> Result { - let module_root = self.project_dir.path().join(project_dir_name); - let module_root_str = module_root.to_str().context("Invalid C# project path")?; - self.spacetime(&[ - "init", - "--non-interactive", - "--lang", - "csharp", - "--dotnet-version", - "10", - "--project-path", - module_root_str, - module_name, - ])?; - - let module_path = module_root.join("spacetimedb"); - fs::write(module_path.join("Lib.cs"), module_source).context("Failed to write C# module code")?; - csharp::prepare_csharp_module(&module_path)?; - - Ok(module_path) - } - - fn prepare_cpp_module_source_internal(&mut self, project_dir_name: &str, module_source: &str) -> Result { - let module_path = self.project_dir.path().join(project_dir_name); - let src_dir = module_path.join("src"); - fs::create_dir_all(&src_dir).context("Failed to create C++ source directory")?; - - let bindings_cpp_path = workspace_root() - .join("crates/bindings-cpp") - .display() - .to_string() - .replace('\\', "/"); - let cmakelists = CPP_SMOKETEST_CMAKELISTS.replace("@SPACETIMEDB_CPP_LIBRARY_PATH@", &bindings_cpp_path); - - fs::write(module_path.join("CMakeLists.txt"), cmakelists).context("Failed to write C++ CMakeLists.txt")?; - fs::write(src_dir.join("lib.cpp"), module_source).context("Failed to write C++ module code")?; - - Ok(module_path) - } - /// Switches to using a precompiled module. /// /// After calling this, subsequent `publish_module*` calls will use the - /// precompiled WASM file instead of building from source. + /// precompiled artifact instead of building from source. pub fn use_precompiled_module(&mut self, name: &str) { let path = modules::precompiled_module(name); - if !path.exists() { + if !path.path().exists() { panic!( "Pre-compiled module '{}' not found at {:?}. \ Run `cargo smoketest` to build pre-compiled modules during warmup.", @@ -1312,7 +1106,7 @@ impl Smoketest { ); } eprintln!("[PRECOMPILED] Switching to pre-compiled module: {}", name); - self.precompiled_wasm_path = Some(path); + self.precompiled_module = Some(path); } /// Switches to using an explicit precompiled WASM path. @@ -1325,7 +1119,7 @@ impl Smoketest { bail!("Pre-compiled wasm not found at {}", path.display()); } eprintln!("[PRECOMPILED] Switching to explicit wasm path: {}", path.display()); - self.precompiled_wasm_path = Some(path.to_path_buf()); + self.precompiled_module = Some(modules::PrecompiledModule::Wasm(path.to_path_buf())); Ok(()) } @@ -1819,7 +1613,7 @@ mod tests { .autopublish(false) .build(); assert!(test.database_identity.is_none()); - assert!(test.precompiled_wasm_path.is_none()); + assert!(test.precompiled_module.is_none()); assert!(!test.project_dir.path().join("Cargo.toml").exists()); assert!(!test.project_dir.path().join("src").exists()); } diff --git a/crates/smoketests/src/modules.rs b/crates/smoketests/src/modules.rs index 83858819f5b..43889f1baf6 100644 --- a/crates/smoketests/src/modules.rs +++ b/crates/smoketests/src/modules.rs @@ -1,35 +1,57 @@ //! Registry for pre-compiled smoketest modules. //! -//! This module provides access to WASM modules that are pre-compiled during the +//! This module provides access to WASM and JavaScript modules built 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. +//! Rust outputs live in the nested workspace's target directory; other language +//! outputs live in `target/smoketest-precompiled`. Both respect `CARGO_TARGET_DIR`. //! -//! Module names are automatically derived from WASM filenames: +//! Module names are derived from artifact filenames: //! - `smoketest_module_foo_bar.wasm` → module name `foo-bar` use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{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(); +/// Registry mapping names to prepared artifacts. +static REGISTRY: OnceLock> = OnceLock::new(); -/// Returns the path to a pre-compiled module's WASM file. +/// A prepared module and the format expected by `spacetime publish`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PrecompiledModule { + Wasm(PathBuf), + JavaScript(PathBuf), +} + +impl PrecompiledModule { + pub fn path(&self) -> &Path { + match self { + Self::Wasm(path) | Self::JavaScript(path) => path, + } + } + + pub fn publish_flag(&self) -> &'static str { + match self { + Self::Wasm(_) => "--bin-path", + Self::JavaScript(_) => "--js-path", + } + } +} + +/// Returns a named precompiled module. /// /// # 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 { +/// to preparation yet, or its language toolchain was unavailable. +pub fn precompiled_module(name: &str) -> PrecompiledModule { let registry = REGISTRY.get_or_init(build_registry); registry.get(name).cloned().unwrap_or_else(|| { panic!( - "Unknown precompiled module: '{}'. Available modules: {:?}", + "Unknown precompiled module: '{}'. Run `cargo smoketest prepare` with its language toolchain installed. Available modules: {:?}", name, registry.keys().collect::>() ) @@ -45,43 +67,61 @@ fn modules_target_dir() -> PathBuf { 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(); +/// Directory transferred with Rust WASM files in CI support archives. +pub fn prepared_modules_dir() -> PathBuf { + std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target")) + .join("smoketest-precompiled") +} - let Ok(entries) = std::fs::read_dir(&target) else { - return reg; - }; +fn build_registry() -> HashMap { + scan_modules(&[modules_target_dir(), prepared_modules_dir()]).expect("Failed to load precompiled modules") +} - for entry in entries.filter_map(Result::ok) { - let path = entry.path(); - if let Some(module_name) = wasm_to_module_name(path.clone()) { - reg.insert(module_name, path); +fn scan_modules(directories: &[PathBuf]) -> anyhow::Result> { + let mut reg = HashMap::new(); + for directory in directories { + let entries = match std::fs::read_dir(directory) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, + Err(err) => return Err(err.into()), + }; + for entry in entries { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let path = entry.path(); + let Some(module_name) = artifact_to_module_name(&path) else { + continue; + }; + let module = match path.extension().and_then(|s| s.to_str()) { + Some("wasm") => PrecompiledModule::Wasm(path), + Some("js") => PrecompiledModule::JavaScript(path), + _ => continue, + }; + anyhow::ensure!( + !reg.contains_key(&module_name), + "Duplicate precompiled module: {module_name}" + ); + reg.insert(module_name, module); } } - - reg + Ok(reg) } /// Extract module name: smoketest_module_foo_bar.wasm -> foo-bar -fn wasm_to_module_name(path: PathBuf) -> Option { - let filename = path.file_name()?.to_str()?; - // Only process smoketest_module_*.wasm files - if !filename.starts_with("smoketest_module_") || !filename.ends_with(".wasm") { - return None; +fn artifact_to_module_name(path: &Path) -> Option { + match path.extension()?.to_str()? { + "wasm" | "js" => Some( + path.file_stem()? + .to_str()? + .strip_prefix("smoketest_module_")? + .replace('_', "-"), + ), + _ => None, } - Some( - filename - .strip_prefix("smoketest_module_") - .unwrap() - .strip_suffix(".wasm") - .unwrap() - .replace('_', "-"), - ) } #[cfg(test)] @@ -93,7 +133,29 @@ mod tests { // Test the naming convention let filename = "smoketest_module_foo_bar.wasm"; let expected = "foo-bar"; - let actual = wasm_to_module_name(PathBuf::from(filename)); + let actual = artifact_to_module_name(Path::new(filename)); assert_eq!(actual, Some(expected.to_string())); } + + #[test] + fn discovers_both_formats_and_rejects_ambiguous_names() { + let dir = tempfile::tempdir().unwrap(); + for name in [ + "smoketest_module_foo_bar.wasm", + "smoketest_module_script.js", + "unrelated.wasm", + ] { + std::fs::write(dir.path().join(name), []).unwrap(); + } + let directories = [dir.path().to_owned()]; + let registry = scan_modules(&directories).unwrap(); + assert_eq!(registry.len(), 2); + assert_eq!(registry["foo-bar"].publish_flag(), "--bin-path"); + assert_eq!(registry["script"].publish_flag(), "--js-path"); + std::fs::write(dir.path().join("smoketest_module_foo-bar.js"), []).unwrap(); + assert!(scan_modules(&directories) + .unwrap_err() + .to_string() + .contains("Duplicate")); + } } diff --git a/crates/smoketests/src/prepare.rs b/crates/smoketests/src/prepare.rs new file mode 100644 index 00000000000..2812eccd261 --- /dev/null +++ b/crates/smoketests/src/prepare.rs @@ -0,0 +1,455 @@ +//! Builds non-Rust fixtures once, before servers or test processes are started. + +use anyhow::{bail, ensure, Context, Result}; +use regex::Regex; +use std::fs; +use std::path::Path; +use std::process::Command; + +use crate::{build_typescript_sdk, csharp, have_emscripten, modules, pnpm, pnpm_path, workspace_root}; + +const HTTP_DOC: &str = "docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md"; +const DOTNET_DISABLED: &str = ".dotnet-disabled"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Language { + TypeScript, + CSharp, + Cpp, +} + +impl Language { + fn name(self) -> &'static str { + match self { + Self::TypeScript => "typescript", + Self::CSharp => "csharp", + Self::Cpp => "cpp", + } + } + + fn extension(self) -> &'static str { + match self { + Self::TypeScript => "js", + Self::CSharp | Self::Cpp => "wasm", + } + } + + fn available(self) -> bool { + match self { + Self::TypeScript => pnpm_path().is_some(), + Self::CSharp => Command::new("dotnet").arg("--list-sdks").output().is_ok_and(|output| { + output.status.success() + && supports_csharp_fixtures(std::env::consts::OS, &String::from_utf8_lossy(&output.stdout)) + }), + Self::Cpp => have_emscripten(), + } + } +} + +fn supports_csharp_fixtures(host: &str, sdks: &str) -> bool { + // These fixtures use .NET 10 NativeAOT, which the CLI does not support on macOS. + host != "macos" && sdks.lines().any(|line| line.starts_with("10.")) +} + +// Tutorial fixtures are extracted from current documentation at preparation time. +const FIXTURES: &[(Language, &str, &str)] = &[ + ( + Language::Cpp, + "http-routes-cpp-basic", + include_str!("../modules/cpp/http-routes-cpp-basic.cpp"), + ), + ( + Language::Cpp, + "http-routes-cpp-example", + include_str!("../modules/cpp/http-routes-cpp-example.cpp"), + ), + ( + Language::Cpp, + "http-routes-cpp-strict-non-root", + include_str!("../modules/cpp/http-routes-cpp-strict-non-root.cpp"), + ), + ( + Language::Cpp, + "http-routes-cpp-strict-root", + include_str!("../modules/cpp/http-routes-cpp-strict-root.cpp"), + ), + ( + Language::Cpp, + "http-routes-cpp-full-uri", + include_str!("../modules/cpp/http-routes-cpp-full-uri.cpp"), + ), + ( + Language::Cpp, + "http-routes-cpp-request-body", + include_str!("../modules/cpp/http-routes-cpp-request-body.cpp"), + ), + ( + Language::TypeScript, + "http-routes-typescript-basic", + include_str!("../modules/typescript/http-routes-typescript-basic.ts"), + ), + ( + Language::TypeScript, + "http-routes-typescript-example", + include_str!("../modules/typescript/http-routes-typescript-example.ts"), + ), + ( + Language::TypeScript, + "http-routes-typescript-strict-non-root", + include_str!("../modules/typescript/http-routes-typescript-strict-non-root.ts"), + ), + ( + Language::TypeScript, + "http-routes-typescript-strict-root", + include_str!("../modules/typescript/http-routes-typescript-strict-root.ts"), + ), + ( + Language::TypeScript, + "http-routes-typescript-full-uri", + include_str!("../modules/typescript/http-routes-typescript-full-uri.ts"), + ), + ( + Language::TypeScript, + "http-routes-typescript-request-body", + include_str!("../modules/typescript/http-routes-typescript-request-body.ts"), + ), + ( + Language::CSharp, + "http-routes-csharp-basic", + include_str!("../modules/csharp/http-routes-csharp-basic.cs"), + ), + ( + Language::CSharp, + "http-routes-csharp-example", + include_str!("../modules/csharp/http-routes-csharp-example.cs"), + ), + ( + Language::CSharp, + "http-routes-csharp-strict-non-root", + include_str!("../modules/csharp/http-routes-csharp-strict-non-root.cs"), + ), + ( + Language::CSharp, + "http-routes-csharp-strict-root", + include_str!("../modules/csharp/http-routes-csharp-strict-root.cs"), + ), + ( + Language::CSharp, + "http-routes-csharp-full-uri", + include_str!("../modules/csharp/http-routes-csharp-full-uri.cs"), + ), + ( + Language::CSharp, + "http-routes-csharp-request-body", + include_str!("../modules/csharp/http-routes-csharp-request-body.cs"), + ), + ( + Language::TypeScript, + "column-defaults-ts-initial", + include_str!("../modules/typescript/column-defaults-ts-initial.ts"), + ), + ( + Language::TypeScript, + "column-defaults-ts-updated", + include_str!("../modules/typescript/column-defaults-ts-updated.ts"), + ), + ( + Language::CSharp, + "column-defaults-csharp-initial", + include_str!("../modules/csharp/column-defaults-csharp-initial.cs"), + ), + ( + Language::CSharp, + "column-defaults-csharp-updated", + include_str!("../modules/csharp/column-defaults-csharp-updated.cs"), + ), + ( + Language::Cpp, + "column-defaults-cpp-initial", + include_str!("../modules/cpp/column-defaults-cpp-initial.cpp"), + ), + ( + Language::Cpp, + "column-defaults-cpp-updated", + include_str!("../modules/cpp/column-defaults-cpp-updated.cpp"), + ), + ( + Language::TypeScript, + "views-subscribe-typescript", + include_str!("../modules/typescript/views-subscribe-typescript.ts"), + ), + ( + Language::CSharp, + "views-count-csharp", + include_str!("../modules/csharp/views-count-csharp.cs"), + ), + ( + Language::TypeScript, + "views-count-typescript", + include_str!("../modules/typescript/views-count-typescript.ts"), + ), + ( + Language::CSharp, + "views-csharp", + include_str!("../modules/csharp/views-csharp.cs"), + ), + ( + Language::TypeScript, + "modules-basic-ts", + include_str!("../modules/typescript/modules-basic-ts.ts"), + ), + ( + Language::TypeScript, + "typescript-add-optional-columns-v1", + include_str!("../modules/typescript/typescript-add-optional-columns-v1.ts"), + ), + ( + Language::TypeScript, + "typescript-add-optional-columns-v2", + include_str!("../modules/typescript/typescript-add-optional-columns-v2.ts"), + ), + ( + Language::TypeScript, + "typescript-change-source-name-v2", + include_str!("../modules/typescript/typescript-change-source-name-v2.ts"), + ), +]; + +/// Preserve disabled C# support when an archive moves to another machine. +pub fn dotnet_prepared() -> bool { + !modules::prepared_modules_dir().join(DOTNET_DISABLED).exists() +} + +/// Builds enabled fixtures. CI requires every enabled toolchain; local filtered +/// runs may skip unavailable languages. Selecting a missing artifact still fails. +pub fn prepare_modules(cli: &Path, dotnet: bool, require_toolchains: bool) -> Result<()> { + prepare_into(&modules::prepared_modules_dir(), |output| { + if !dotnet { + fs::write(output.join(DOTNET_DISABLED), [])?; + } + let project = tempfile::tempdir()?; + let config = project.path().join("config.toml"); + for language in [Language::TypeScript, Language::CSharp, Language::Cpp] { + if language == Language::CSharp && !dotnet { + continue; + } + if !language.available() { + ensure!( + !require_toolchains, + "Missing toolchain for {} fixtures", + language.name() + ); + eprintln!("Skipping {} fixtures: toolchain unavailable", language.name()); + continue; + } + if language == Language::TypeScript { + build_typescript_sdk()?; + } + for &(fixture_language, name, source) in FIXTURES { + if language == fixture_language { + build_fixture(cli, &config, project.path(), output, language, name, source) + .with_context(|| format!("preparing fixture {name}"))?; + } + } + let doc = fs::read_to_string(workspace_root().join(HTTP_DOC))?; + let source = tutorial_source(&doc, language)?; + let name = format!("http-handlers-docs-{}", language.name()); + build_fixture(cli, &config, project.path(), output, language, &name, &source) + .with_context(|| format!("preparing fixture {name}"))?; + } + Ok(()) + }) +} + +// Invalidate previous artifacts first, and expose the new set only after success. +fn prepare_into(destination: &Path, build: impl FnOnce(&Path) -> Result<()>) -> Result<()> { + if destination.exists() { + fs::remove_dir_all(destination)?; + } + let parent = destination.parent().context("Missing preparation output parent")?; + fs::create_dir_all(parent)?; + let staging = tempfile::tempdir_in(parent)?; + build(staging.path())?; + fs::rename(staging.path(), destination)?; + Ok(()) +} + +fn tutorial_source(doc: &str, language: Language) -> Result { + let language_pattern = match language { + Language::TypeScript => "(?:ts|typescript)", + Language::CSharp => "csharp", + Language::Cpp => r"(?:cpp|c\+\+)", + }; + let doc = doc.replace("\r\n", "\n"); + let regex = Regex::new(&format!(r"```{language_pattern}\n([\s\S]*?)\n```"))?; + let blocks = regex + .captures_iter(&doc) + .map(|cap| cap[1].to_string()) + .collect::>(); + ensure!(!blocks.is_empty(), "No {} code blocks in {HTTP_DOC}", language.name()); + Ok(blocks.join("\n\n")) +} + +fn spacetime(cli: &Path, config: &Path, cwd: &Path, args: &[&str]) -> Result<()> { + let output = Command::new(cli) + .arg("--config-path") + .arg(config) + .args(args) + .current_dir(cwd) + .output()?; + if !output.status.success() { + bail!( + "spacetime {args:?} failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) +} + +fn build_fixture( + cli: &Path, + config: &Path, + project: &Path, + output: &Path, + language: Language, + name: &str, + source: &str, +) -> Result<()> { + eprintln!("Building precompiled fixture {name}..."); + let root = project.join(name); + let module = if language == Language::Cpp { + fs::create_dir_all(root.join("src"))?; + let bindings = workspace_root() + .join("crates/bindings-cpp") + .display() + .to_string() + .replace('\\', "/"); + let cmake = include_str!("../modules/cpp/CMakeLists.txt").replace("@SPACETIMEDB_CPP_LIBRARY_PATH@", &bindings); + fs::write(root.join("CMakeLists.txt"), cmake)?; + fs::write(root.join("src/lib.cpp"), source)?; + root + } else { + let mut args = vec![ + "init", + "--non-interactive", + "--lang", + language.name(), + "--project-path", + root.to_str().context("Invalid fixture path")?, + name, + ]; + if language == Language::CSharp { + args.extend(["--dotnet-version", "10"]); + } + spacetime(cli, config, project, &args)?; + let module = root.join("spacetimedb"); + if language == Language::TypeScript { + fs::write(module.join("src/index.ts"), source)?; + let _ = pnpm(&["uninstall", "spacetimedb"], &module); + let bindings = workspace_root().join("crates/bindings-typescript"); + pnpm( + &[ + "install", + bindings.to_str().context("Invalid TypeScript bindings path")?, + ], + &module, + )?; + } else { + fs::write(module.join("Lib.cs"), source)?; + csharp::prepare_csharp_module(&module)?; + } + module + }; + let mut args = vec![ + "build", + "--module-path", + module.to_str().context("Invalid module path")?, + ]; + if language == Language::CSharp { + args.extend(["--dotnet-version", "10"]); + } + spacetime(cli, config, project, &args)?; + if language == Language::CSharp { + csharp::verify_csharp_module_restore(&module)?; + } + let candidates: &[&str] = match language { + Language::TypeScript => &["dist/bundle.js"], + Language::CSharp => &[ + "bin/Release/net10.0/wasi-wasm/native/StdbModule.wasm", + "bin/Release/net10.0/native/StdbModule.wasm", + ], + Language::Cpp => &["build/lib.wasm", "build/Release/lib.wasm"], + }; + let artifacts = candidates + .iter() + .map(|path| module.join(path)) + .filter(|path| path.is_file()) + .collect::>(); + ensure!( + artifacts.len() == 1, + "Expected one artifact for {name}, found {artifacts:?}" + ); + fs::copy( + &artifacts[0], + output.join(format!( + "smoketest_module_{}.{}", + name.replace('-', "_"), + language.extension() + )), + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn csharp_preparation_requires_a_supported_dotnet_10_host() { + assert!(!supports_csharp_fixtures("linux", "8.0.100 [/sdk]\n9.0.100 [/sdk]")); + assert!(supports_csharp_fixtures("linux", "8.0.100 [/sdk]\n10.0.100 [/sdk]")); + assert!(supports_csharp_fixtures("windows", "10.0.100 [C:\\sdk]\r\n")); + assert!(!supports_csharp_fixtures("macos", "10.0.100 [/sdk]")); + } + + #[test] + fn failed_preparation_invalidates_previous_and_partial_artifacts() { + let temp = tempfile::tempdir().unwrap(); + let dest = temp.path().join("prepared"); + fs::create_dir(&dest).unwrap(); + fs::write(dest.join("stale.wasm"), []).unwrap(); + let result = prepare_into(&dest, |staging| { + fs::write(staging.join("partial.wasm"), [])?; + bail!("build failed") + }); + assert!(result.is_err()); + assert!(!dest.exists()); + prepare_into(&dest, |staging| { + fs::write(staging.join(DOTNET_DISABLED), [])?; + Ok(()) + }) + .unwrap(); + assert!(dest.join(DOTNET_DISABLED).exists()); + assert!(!dest.join("stale.wasm").exists()); + } + + #[test] + fn tutorial_uses_current_document_and_requires_matching_blocks() { + let doc = "```typescript\r\nfirst\r\n```\r\n```ts\r\nsecond\r\n```"; + assert_eq!(tutorial_source(doc, Language::TypeScript).unwrap(), "first\n\nsecond"); + assert_eq!( + tutorial_source(&doc.replace("first", "changed"), Language::TypeScript).unwrap(), + "changed\n\nsecond" + ); + assert!(tutorial_source(doc, Language::Cpp).is_err()); + } + + #[test] + fn fixture_names_are_unique() { + let mut names = std::collections::HashSet::new(); + for &(_, name, _) in FIXTURES { + assert!(names.insert(name.replace('_', "-")), "Duplicate fixture {name}"); + } + } +} diff --git a/crates/smoketests/tests/cluster/column_defaults.rs b/crates/smoketests/tests/cluster/column_defaults.rs index feef4e2c5ae..db74dd499c8 100644 --- a/crates/smoketests/tests/cluster/column_defaults.rs +++ b/crates/smoketests/tests/cluster/column_defaults.rs @@ -1,6 +1,4 @@ -use spacetimedb_smoketests::{ - random_string, require_dotnet, require_emscripten, require_pnpm, ModuleLanguage, Smoketest, -}; +use spacetimedb_smoketests::{allow_dotnet, random_string, Smoketest}; const EXPECTED_DEFAULTS: &[(&str, &str)] = &[ ("bool_value", "true"), @@ -32,23 +30,20 @@ fn test_defaults(test: &mut Smoketest, publish_updated: impl FnOnce(&mut Smokete } } -fn test_source_defaults(language: ModuleLanguage, project_name: &str, initial: &str, updated: &str) { +fn test_precompiled_defaults(project_name: &str) { let mut test = Smoketest::builder().autopublish(false).build(); let database_name = format!("column-defaults-{project_name}-{}", random_string()); let initial_project_name = format!("{project_name}-initial"); let updated_project_name = format!("{project_name}-updated"); - test.publish() - .name(&database_name) - .source(language, &initial_project_name, initial) - .run() - .unwrap(); + test.use_precompiled_module(&initial_project_name); + test.publish().name(&database_name).run().unwrap(); test_defaults(&mut test, |test| { + test.use_precompiled_module(&updated_project_name); test.publish() .current_database() .unwrap() .break_clients(true) - .source(language, &updated_project_name, updated) .run() .unwrap(); }); @@ -72,175 +67,18 @@ fn test_rust_column_defaults() { #[test] fn test_typescript_column_defaults() { - require_pnpm!(); - test_source_defaults( - ModuleLanguage::TypeScript, - "column-defaults-ts", - TYPESCRIPT_INITIAL, - TYPESCRIPT_UPDATED, - ); + test_precompiled_defaults("column-defaults-ts"); } #[test] fn test_csharp_column_defaults() { - require_dotnet!(); - test_source_defaults( - ModuleLanguage::CSharp, - "column-defaults-csharp", - CSHARP_INITIAL, - CSHARP_UPDATED, - ); + if !allow_dotnet() { + return; + } + test_precompiled_defaults("column-defaults-csharp"); } #[test] fn test_cpp_column_defaults() { - require_emscripten!(); - test_source_defaults(ModuleLanguage::Cpp, "column-defaults-cpp", CPP_INITIAL, CPP_UPDATED); -} - -const TYPESCRIPT_INITIAL: &str = r#" -import { schema, t, table } from "spacetimedb/server"; - -const defaultsTestTable = table( - { name: "defaults_test_table", public: true }, - { id: t.u32() } -); - -export default schema({ defaultsTestTable }); -"#; - -const TYPESCRIPT_UPDATED: &str = r#" -import { schema, t, table } from "spacetimedb/server"; - -const defaultsTestTable = table( - { name: "defaults_test_table", public: true }, - { - id: t.u32(), - bool_value: t.bool().default(true), - i8_value: t.i8().default(-8), - u8_value: t.u8().default(8), - i16_value: t.i16().default(-16), - u16_value: t.u16().default(16), - i32_value: t.i32().default(-32), - u32_value: t.u32().default(32), - i64_value: t.i64().default(-64n), - u64_value: t.u64().default(64n), - f32_positive_value: t.f32().default(32.5), - f32_negative_value: t.f32().default(-32.5), - f64_positive_value: t.f64().default(64.25), - f64_negative_value: t.f64().default(-64.25), - string_value: t.string().default("default string"), - } -); - -export default schema({ defaultsTestTable }); -"#; - -const CSHARP_INITIAL: &str = r#" -using SpacetimeDB; - -public static partial class Module -{ - [Table(Accessor = "defaults_test_table", Public = true)] - public partial struct DefaultsTestTable - { - public uint id; - } + test_precompiled_defaults("column-defaults-cpp"); } -"#; - -const CSHARP_UPDATED: &str = r#" -using SpacetimeDB; - -public static partial class Module -{ - [Table(Accessor = "defaults_test_table", Public = true)] - public partial struct DefaultsTestTable - { - public uint id; - [Default(true)] public bool bool_value; - [Default((sbyte)-8)] public sbyte i8_value; - [Default((byte)8)] public byte u8_value; - [Default((short)-16)] public short i16_value; - [Default((ushort)16)] public ushort u16_value; - [Default(-32)] public int i32_value; - [Default(32U)] public uint u32_value; - [Default(-64L)] public long i64_value; - [Default(64UL)] public ulong u64_value; - [Default(32.5f)] public float f32_positive_value; - [Default(-32.5f)] public float f32_negative_value; - [Default(64.25)] public double f64_positive_value; - [Default(-64.25)] public double f64_negative_value; - [Default("default string")] public string string_value; - } -} -"#; - -const CPP_INITIAL: &str = r#" -#include "spacetimedb.h" - -using namespace SpacetimeDB; - -struct DefaultsTestTable { - uint32_t id; -}; -SPACETIMEDB_STRUCT(DefaultsTestTable, id) -SPACETIMEDB_TABLE(DefaultsTestTable, defaults_test_table, Public) -"#; - -const CPP_UPDATED: &str = r#" -#include "spacetimedb.h" - -using namespace SpacetimeDB; - -struct DefaultsTestTable { - uint32_t id; - bool bool_value; - int8_t i8_value; - uint8_t u8_value; - int16_t i16_value; - uint16_t u16_value; - int32_t i32_value; - uint32_t u32_value; - int64_t i64_value; - uint64_t u64_value; - float f32_positive_value; - float f32_negative_value; - double f64_positive_value; - double f64_negative_value; - std::string string_value; -}; -SPACETIMEDB_STRUCT( - DefaultsTestTable, - id, - bool_value, - i8_value, - u8_value, - i16_value, - u16_value, - i32_value, - u32_value, - i64_value, - u64_value, - f32_positive_value, - f32_negative_value, - f64_positive_value, - f64_negative_value, - string_value -) -SPACETIMEDB_TABLE(DefaultsTestTable, defaults_test_table, Public) -FIELD_Default(defaults_test_table, bool_value, true) -FIELD_Default(defaults_test_table, i8_value, int8_t(-8)) -FIELD_Default(defaults_test_table, u8_value, uint8_t(8)) -FIELD_Default(defaults_test_table, i16_value, int16_t(-16)) -FIELD_Default(defaults_test_table, u16_value, uint16_t(16)) -FIELD_Default(defaults_test_table, i32_value, int32_t(-32)) -FIELD_Default(defaults_test_table, u32_value, uint32_t(32)) -FIELD_Default(defaults_test_table, i64_value, int64_t(-64)) -FIELD_Default(defaults_test_table, u64_value, uint64_t(64)) -FIELD_Default(defaults_test_table, f32_positive_value, float(32.5)) -FIELD_Default(defaults_test_table, f32_negative_value, float(-32.5)) -FIELD_Default(defaults_test_table, f64_positive_value, double(64.25)) -FIELD_Default(defaults_test_table, f64_negative_value, double(-64.25)) -FIELD_Default(defaults_test_table, string_value, std::string("default string")) -"#; diff --git a/crates/smoketests/tests/cluster/http_routes.rs b/crates/smoketests/tests/cluster/http_routes.rs index bf0b46414c0..cc93d2665a4 100644 --- a/crates/smoketests/tests/cluster/http_routes.rs +++ b/crates/smoketests/tests/cluster/http_routes.rs @@ -1,861 +1,7 @@ -use regex::Regex; -use spacetimedb_smoketests::{ - random_string, require_dotnet, require_emscripten, require_pnpm, workspace_root, ModuleLanguage, Smoketest, -}; -use std::{fs, path::Path}; - -const CPP_MODULE_CODE: &str = r#"#include "spacetimedb.h" - -using namespace SpacetimeDB; - -struct Entry { - uint64_t id; - std::string value; -}; -SPACETIMEDB_STRUCT(Entry, id, value) -SPACETIMEDB_TABLE(Entry, entry, Public) - -namespace { - -std::string header_value_utf8(const HttpRequest& request, const std::string& header_name) { - for (const auto& header : request.headers) { - if (header.name == header_name) { - return std::string(header.value.begin(), header.value.end()); - } - } - return ""; -} - -HttpResponse text_response(uint16_t status_code, std::string body) { - return HttpResponse{ - status_code, - HttpVersion::Http11, - { HttpHeader{"content-type", "text/plain; charset=utf-8"} }, - HttpBody::from_string(body), - }; -} - -} // namespace - -SPACETIMEDB_HTTP_HANDLER(get_simple, HandlerContext ctx, HttpRequest request) { - return text_response(200, "ok"); -} - -SPACETIMEDB_HTTP_HANDLER(post_insert, HandlerContext ctx, HttpRequest request) { - ctx.with_tx([](TxContext& tx) { - uint64_t id = tx.db[entry].count(); - tx.db[entry].insert(Entry{ id, "posted" }); - }); - return text_response(200, "inserted"); -} - -SPACETIMEDB_HTTP_HANDLER(get_count, HandlerContext ctx, HttpRequest request) { - uint64_t count = ctx.with_tx([](TxContext& tx) -> uint64_t { - return tx.db[entry].count(); - }); - return text_response(200, std::to_string(count)); -} - -SPACETIMEDB_HTTP_HANDLER(any_handler, HandlerContext ctx, HttpRequest request) { - return text_response(200, "any"); -} - -SPACETIMEDB_HTTP_HANDLER(header_echo, HandlerContext ctx, HttpRequest request) { - return text_response(200, header_value_utf8(request, "x-echo")); -} - -SPACETIMEDB_HTTP_HANDLER(set_response_header, HandlerContext ctx, HttpRequest request) { - return HttpResponse{ - 200, - HttpVersion::Http11, - { HttpHeader{"x-response", "set"} }, - HttpBody::from_string("header-set"), - }; -} - -SPACETIMEDB_HTTP_HANDLER(body_handler, HandlerContext ctx, HttpRequest request) { - return text_response(200, "non-empty"); -} - -SPACETIMEDB_HTTP_HANDLER(teapot, HandlerContext ctx, HttpRequest request) { - return text_response(418, "teapot"); -} - -SPACETIMEDB_HTTP_ROUTER(router) { - return Router() - .get("/get", get_simple) - .post("/post", post_insert) - .get("/count", get_count) - .any("/any", any_handler) - .get("/header", header_echo) - .get("/set-header", set_response_header) - .get("/body", body_handler) - .get("/teapot", teapot); -} -"#; - -const CPP_EXAMPLE_MODULE_CODE: &str = r#"#include "spacetimedb.h" - -using namespace SpacetimeDB; - -struct Data { - uint64_t id; - std::vector body; -}; -SPACETIMEDB_STRUCT(Data, id, body) -SPACETIMEDB_TABLE(Data, data, Public) -FIELD_PrimaryKeyAutoInc(data, id) - -namespace { - -HttpResponse bytes_response(uint16_t status_code, std::vector body) { - return HttpResponse{ - status_code, - HttpVersion::Http11, - {}, - HttpBody{std::move(body)}, - }; -} - -HttpResponse text_response(uint16_t status_code, std::string body) { - return HttpResponse{ - status_code, - HttpVersion::Http11, - {}, - HttpBody::from_string(body), - }; -} - -std::string query_value(const std::string& uri, const std::string& key) { - std::string needle = "?" + key + "="; - size_t pos = uri.find(needle); - if (pos == std::string::npos) { - needle = "&" + key + "="; - pos = uri.find(needle); - } - if (pos == std::string::npos) { - return ""; - } - pos += needle.size(); - size_t end = uri.find('&', pos); - return uri.substr(pos, end == std::string::npos ? std::string::npos : end - pos); -} - -bool try_parse_u64(const std::string& text, uint64_t& value) { - if (text.empty()) { - return false; - } - uint64_t result = 0; - for (char c : text) { - if (c < '0' || c > '9') { - return false; - } - result = (result * 10) + static_cast(c - '0'); - } - value = result; - return true; -} - -} // namespace - -SPACETIMEDB_HTTP_HANDLER(insert, HandlerContext ctx, HttpRequest request) { - std::vector body = request.body.to_bytes(); - uint64_t id = ctx.with_tx([&](TxContext& tx) -> uint64_t { - return tx.db[data].insert(Data{0, body}).id; - }); - return text_response(200, std::to_string(id)); -} - -SPACETIMEDB_HTTP_HANDLER(retrieve, HandlerContext ctx, HttpRequest request) { - uint64_t id = 0; - if (!try_parse_u64(query_value(request.uri, "id"), id)) { - return text_response(500, "invalid id"); - } - - auto body = ctx.with_tx([&](TxContext& tx) -> std::optional> { - auto row = tx.db[data_id].find(id); - if (row.has_value()) { - return row->body; - } - return std::nullopt; - }); - - if (body.has_value()) { - return bytes_response(200, std::move(body.value())); - } - return bytes_response(404, {}); -} - -SPACETIMEDB_HTTP_ROUTER(router) { - return Router().post("/insert", insert).get("/retrieve", retrieve); -} -"#; - -const CPP_STRICT_ROOT_ROUTING_MODULE_CODE: &str = r#"#include "spacetimedb.h" - -using namespace SpacetimeDB; - -namespace { - -HttpResponse text_response(const std::string& body) { - return HttpResponse{200, HttpVersion::Http11, {}, HttpBody::from_string(body)}; -} - -} // namespace - -SPACETIMEDB_HTTP_HANDLER(empty_root, HandlerContext ctx, HttpRequest request) { - return text_response("empty"); -} - -SPACETIMEDB_HTTP_HANDLER(slash_root, HandlerContext ctx, HttpRequest request) { - return text_response("slash"); -} - -SPACETIMEDB_HTTP_HANDLER(foo, HandlerContext ctx, HttpRequest request) { - return text_response("foo"); -} - -SPACETIMEDB_HTTP_HANDLER(foo_slash, HandlerContext ctx, HttpRequest request) { - return text_response("foo-slash"); -} - -SPACETIMEDB_HTTP_ROUTER(router) { - return Router() - .get("", empty_root) - .get("/", slash_root) - .get("/foo", foo) - .get("/foo/", foo_slash); -} -"#; - -const CPP_STRICT_NON_ROOT_ROUTING_MODULE_CODE: &str = r#"#include "spacetimedb.h" - -using namespace SpacetimeDB; - -namespace { - -HttpResponse text_response(const std::string& body) { - return HttpResponse{200, HttpVersion::Http11, {}, HttpBody::from_string(body)}; -} - -} // namespace - -SPACETIMEDB_HTTP_HANDLER(foo, HandlerContext ctx, HttpRequest request) { - return text_response("foo"); -} - -SPACETIMEDB_HTTP_HANDLER(foo_slash, HandlerContext ctx, HttpRequest request) { - return text_response("foo-slash"); -} - -SPACETIMEDB_HTTP_ROUTER(router) { - return Router() - .get("/foo", foo) - .get("/foo/", foo_slash); -} -"#; - -const CPP_FULL_URI_MODULE_CODE: &str = r#"#include "spacetimedb.h" - -using namespace SpacetimeDB; - -SPACETIMEDB_HTTP_HANDLER(echo_uri, HandlerContext ctx, HttpRequest request) { - return HttpResponse{ - 200, - HttpVersion::Http11, - {}, - HttpBody::from_string(request.uri), - }; -} - -SPACETIMEDB_HTTP_ROUTER(router) { - return Router().get("/echo-uri", echo_uri); -} -"#; - -const CPP_HANDLE_REQUEST_BODY_MODULE_CODE: &str = r#"#include "spacetimedb.h" -#include - -using namespace SpacetimeDB; - -namespace { - -HttpResponse bytes_response(uint16_t status_code, std::vector body) { - return HttpResponse{status_code, HttpVersion::Http11, {}, HttpBody{std::move(body)}}; -} - -HttpResponse text_response(uint16_t status_code, const std::string& body) { - return HttpResponse{status_code, HttpVersion::Http11, {}, HttpBody::from_string(body)}; -} - -} // namespace - -SPACETIMEDB_HTTP_HANDLER(reverse_bytes, HandlerContext ctx, HttpRequest request) { - std::vector reversed = request.body.to_bytes(); - std::reverse(reversed.begin(), reversed.end()); - return bytes_response(200, std::move(reversed)); -} - -SPACETIMEDB_HTTP_HANDLER(reverse_words, HandlerContext ctx, HttpRequest request) { - const std::vector bytes = request.body.to_bytes(); - std::string body(bytes.begin(), bytes.end()); - if (body.find(static_cast(0x80)) != std::string::npos) { - return text_response(400, "request body must be valid UTF-8"); - } - - std::vector words; - size_t start = 0; - while (true) { - size_t pos = body.find(' ', start); - words.push_back(body.substr(start, pos == std::string::npos ? std::string::npos : pos - start)); - if (pos == std::string::npos) { - break; - } - start = pos + 1; - } - std::reverse(words.begin(), words.end()); - - std::string reversed; - for (size_t i = 0; i < words.size(); ++i) { - if (i != 0) { - reversed += " "; - } - reversed += words[i]; - } - - return text_response(200, reversed); -} - -SPACETIMEDB_HTTP_ROUTER(router) { - return Router() - .post("/reverse-bytes", reverse_bytes) - .post("/reverse-words", reverse_words); -} -"#; - -const TS_MODULE_CODE: &str = r#"import { Router, SyncResponse, schema, table, t } from "spacetimedb/server"; - -const entry = table( - { name: "entry", public: true }, - { - id: t.u64().primaryKey(), - value: t.string(), - } -); - -const spacetimedb = schema({ entry }); -export default spacetimedb; - -export const get_simple = spacetimedb.httpHandler((_ctx, _req) => - new SyncResponse("ok") -); - -export const post_insert = spacetimedb.httpHandler((ctx, _req) => { - ctx.withTx(tx => { - const id = BigInt(tx.db.entry.count()); - tx.db.entry.insert({ id, value: "posted" }); - }); - return new SyncResponse("inserted"); -}); - -export const get_count = spacetimedb.httpHandler((ctx, _req) => { - const count = ctx.withTx(tx => tx.db.entry.count()); - return new SyncResponse(String(count)); -}); - -export const any_handler = spacetimedb.httpHandler((_ctx, _req) => - new SyncResponse("any") -); - -export const header_echo = spacetimedb.httpHandler((_ctx, req) => - new SyncResponse(req.headers.get("x-echo") ?? "") -); - -export const set_response_header = spacetimedb.httpHandler((_ctx, _req) => - new SyncResponse("header-set", { headers: { "x-response": "set" } }) -); - -export const body_handler = spacetimedb.httpHandler((_ctx, _req) => - new SyncResponse("non-empty") -); - -export const teapot = spacetimedb.httpHandler((_ctx, _req) => - new SyncResponse("teapot", { status: 418 }) -); - -export const router = spacetimedb.httpRouter( - new Router() - .get("/get", get_simple) - .post("/post", post_insert) - .get("/count", get_count) - .any("/any", any_handler) - .get("/header", header_echo) - .get("/set-header", set_response_header) - .get("/body", body_handler) - .get("/teapot", teapot) -); -"#; - -const TS_EXAMPLE_MODULE_CODE: &str = r#"import { Router, SyncResponse, schema, table, t } from "spacetimedb/server"; - -const data = table( - { name: "data" }, - { - id: t.u64().primaryKey().autoInc(), - body: t.array(t.u8()), - } -); - -const spacetimedb = schema({ data }); -export default spacetimedb; - -export const insert = spacetimedb.httpHandler((ctx, req) => { - const body = Array.from(req.bytes()); - const id = ctx.withTx(tx => tx.db.data.insert({ id: 0n, body }).id); - return new SyncResponse(String(id)); -}); - -export const retrieve = spacetimedb.httpHandler((ctx, req) => { - const query = req.uri.split("?", 2)[1] ?? ""; - const idText = query.startsWith("id=") ? query.slice(3) : ""; - const id = BigInt(idText); - const body = ctx.withTx(tx => tx.db.data.id.find(id)?.body); - if (body != null) { - return new SyncResponse(new Uint8Array(body)); - } - return new SyncResponse(null, { status: 404 }); -}); - -export const router = spacetimedb.httpRouter( - new Router().post("/insert", insert).get("/retrieve", retrieve) -); -"#; - -const TS_STRICT_ROOT_ROUTING_MODULE_CODE: &str = r#"import { Router, SyncResponse, schema } from "spacetimedb/server"; - -const spacetimedb = schema({}); -export default spacetimedb; - -export const empty_root = spacetimedb.httpHandler((_ctx, _req) => - new SyncResponse("empty") -); - -export const slash_root = spacetimedb.httpHandler((_ctx, _req) => - new SyncResponse("slash") -); - -export const foo = spacetimedb.httpHandler((_ctx, _req) => - new SyncResponse("foo") -); - -export const foo_slash = spacetimedb.httpHandler((_ctx, _req) => - new SyncResponse("foo-slash") -); - -export const router = spacetimedb.httpRouter( - new Router() - .get("", empty_root) - .get("/", slash_root) - .get("/foo", foo) - .get("/foo/", foo_slash) -); -"#; - -const TS_STRICT_NON_ROOT_ROUTING_MODULE_CODE: &str = r#"import { Router, SyncResponse, schema } from "spacetimedb/server"; - -const spacetimedb = schema({}); -export default spacetimedb; - -export const foo = spacetimedb.httpHandler((_ctx, _req) => - new SyncResponse("foo") -); - -export const foo_slash = spacetimedb.httpHandler((_ctx, _req) => - new SyncResponse("foo-slash") -); - -export const router = spacetimedb.httpRouter( - new Router() - .get("/foo", foo) - .get("/foo/", foo_slash) -); -"#; - -const TS_FULL_URI_MODULE_CODE: &str = r#"import { Router, SyncResponse, schema } from "spacetimedb/server"; - -const spacetimedb = schema({}); -export default spacetimedb; - -export const echo_uri = spacetimedb.httpHandler((_ctx, req) => - new SyncResponse(req.uri) -); - -export const router = spacetimedb.httpRouter( - new Router().get("/echo-uri", echo_uri) -); -"#; - -const TS_HANDLE_REQUEST_BODY_MODULE_CODE: &str = r#"import { Router, SyncResponse, schema } from "spacetimedb/server"; - -const spacetimedb = schema({}); -export default spacetimedb; - -export const reverse_bytes = spacetimedb.httpHandler((_ctx, req) => { - const reversed = req.bytes(); - reversed.reverse(); - return new SyncResponse(reversed); -}); - -export const reverse_words = spacetimedb.httpHandler((_ctx, req) => { - let body; - try { - body = new TextDecoder("utf-8", { fatal: true }).decode(req.bytes()); - } catch { - return new SyncResponse("request body must be valid UTF-8", { status: 400 }); - } - - const reversed = body.split(" ").reverse().join(" "); - return new SyncResponse(reversed); -}); - -export const router = spacetimedb.httpRouter( - new Router() - .post("/reverse-bytes", reverse_bytes) - .post("/reverse-words", reverse_words) -); -"#; - -const CS_MODULE_CODE: &str = r#" -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using SpacetimeDB; - -#pragma warning disable STDB_UNSTABLE -public static partial class Module -{ - [SpacetimeDB.Table(Accessor = "Entry", Name = "entry", Public = true)] - public partial struct Entry - { - [SpacetimeDB.PrimaryKey] - public ulong Id; - - public string Value; - } - - [SpacetimeDB.HttpHandler] - public static HttpResponse GetSimple(HandlerContext ctx, HttpRequest request) - { - return TextResponse(200, "ok"); - } - - [SpacetimeDB.HttpHandler] - public static HttpResponse PostInsert(HandlerContext ctx, HttpRequest request) - { - ctx.WithTx((HandlerTxContext tx) => - { - var id = tx.Db.Entry.Count; - tx.Db.Entry.Insert(new Entry { Id = id, Value = "posted" }); - return 0; - }); - return TextResponse(200, "inserted"); - } - - [SpacetimeDB.HttpHandler] - public static HttpResponse GetCount(HandlerContext ctx, HttpRequest request) - { - var count = ctx.WithTx((HandlerTxContext tx) => tx.Db.Entry.Count); - return TextResponse(200, count.ToString()); - } - - [SpacetimeDB.HttpHandler] - public static HttpResponse AnyHandler(HandlerContext ctx, HttpRequest request) - { - return TextResponse(200, "any"); - } - - [SpacetimeDB.HttpHandler] - public static HttpResponse HeaderEcho(HandlerContext ctx, HttpRequest request) - { - return TextResponse(200, HeaderValueUtf8(request, "x-echo")); - } - - [SpacetimeDB.HttpHandler] - public static HttpResponse SetResponseHeader(HandlerContext ctx, HttpRequest request) - { - return new HttpResponse( - 200, - HttpVersion.Http11, - new List { new("x-response", "set") }, - HttpBody.FromString("header-set") - ); - } - - [SpacetimeDB.HttpHandler] - public static HttpResponse BodyHandler(HandlerContext ctx, HttpRequest request) - { - return TextResponse(200, "non-empty"); - } - - [SpacetimeDB.HttpHandler] - public static HttpResponse Teapot(HandlerContext ctx, HttpRequest request) - { - return TextResponse(418, "teapot"); - } - - [SpacetimeDB.HttpRouter] - public static Router Router() => - SpacetimeDB.Router.New() - .Get("/get", Handlers.GetSimple) - .Post("/post", Handlers.PostInsert) - .Get("/count", Handlers.GetCount) - .Any("/any", Handlers.AnyHandler) - .Get("/header", Handlers.HeaderEcho) - .Get("/set-header", Handlers.SetResponseHeader) - .Get("/body", Handlers.BodyHandler) - .Get("/teapot", Handlers.Teapot); - - private static string HeaderValueUtf8(HttpRequest request, string headerName) - { - foreach (var header in request.Headers) - { - if (string.Equals(header.Name, headerName, StringComparison.OrdinalIgnoreCase)) - { - return Encoding.UTF8.GetString(header.Value); - } - } - return string.Empty; - } - - private static HttpResponse TextResponse(ushort statusCode, string body) => - new( - statusCode, - HttpVersion.Http11, - new List(), - HttpBody.FromString(body) - ); -} -"#; - -const CS_EXAMPLE_MODULE_CODE: &str = r#" -using System.Collections.Generic; -using SpacetimeDB; - -#pragma warning disable STDB_UNSTABLE -public static partial class Module -{ - [SpacetimeDB.Table(Accessor = "Data", Name = "data", Public = true)] - public partial struct Data - { - [SpacetimeDB.PrimaryKey] - [SpacetimeDB.AutoInc] - public ulong Id; - - public byte[] Body; - } - - [SpacetimeDB.HttpHandler] - public static HttpResponse Insert(HandlerContext ctx, HttpRequest request) - { - var body = request.Body.ToBytes(); - var id = ctx.WithTx((HandlerTxContext tx) => tx.Db.Data.Insert(new Data { Id = 0, Body = body }).Id); - return TextResponse(200, id.ToString()); - } - - [SpacetimeDB.HttpHandler] - public static HttpResponse Retrieve(HandlerContext ctx, HttpRequest request) - { - var idText = request.Uri.Split("id=", 2)[1]; - var id = ulong.Parse(idText); - var body = ctx.WithTx((HandlerTxContext tx) => tx.Db.Data.Id.Find(id)?.Body); - - if (body is not null) - { - return BytesResponse(200, body); - } - - return new HttpResponse(404, HttpVersion.Http11, new List(), HttpBody.Empty); - } - - [SpacetimeDB.HttpRouter] - public static Router Router() => - SpacetimeDB.Router.New() - .Post("/insert", Handlers.Insert) - .Get("/retrieve", Handlers.Retrieve); - - private static HttpResponse BytesResponse(ushort statusCode, byte[] body) => - new(statusCode, HttpVersion.Http11, new List(), new HttpBody(body)); - - private static HttpResponse TextResponse(ushort statusCode, string body) => - new(statusCode, HttpVersion.Http11, new List(), HttpBody.FromString(body)); -} -"#; - -const CS_STRICT_ROOT_ROUTING_MODULE_CODE: &str = r#" -using System.Collections.Generic; -using SpacetimeDB; - -public static partial class Module -{ - [SpacetimeDB.HttpHandler] - public static HttpResponse EmptyRoot(HandlerContext ctx, HttpRequest request) - { - return TextResponse("empty"); - } - - [SpacetimeDB.HttpHandler] - public static HttpResponse SlashRoot(HandlerContext ctx, HttpRequest request) - { - return TextResponse("slash"); - } - - [SpacetimeDB.HttpHandler] - public static HttpResponse Foo(HandlerContext ctx, HttpRequest request) - { - return TextResponse("foo"); - } - - [SpacetimeDB.HttpHandler] - public static HttpResponse FooSlash(HandlerContext ctx, HttpRequest request) - { - return TextResponse("foo-slash"); - } - - [SpacetimeDB.HttpRouter] - public static Router Router() => - SpacetimeDB.Router.New() - .Get("", Handlers.EmptyRoot) - .Get("/", Handlers.SlashRoot) - .Get("/foo", Handlers.Foo) - .Get("/foo/", Handlers.FooSlash); - - private static HttpResponse TextResponse(string body) => - new(200, HttpVersion.Http11, new List(), HttpBody.FromString(body)); -} -"#; - -const CS_STRICT_NON_ROOT_ROUTING_MODULE_CODE: &str = r#" -using System.Collections.Generic; -using SpacetimeDB; - -public static partial class Module -{ - [SpacetimeDB.HttpHandler] - public static HttpResponse Foo(HandlerContext ctx, HttpRequest request) - { - return TextResponse("foo"); - } - - [SpacetimeDB.HttpHandler] - public static HttpResponse FooSlash(HandlerContext ctx, HttpRequest request) - { - return TextResponse("foo-slash"); - } - - [SpacetimeDB.HttpRouter] - public static Router Router() => - SpacetimeDB.Router.New() - .Get("/foo", Handlers.Foo) - .Get("/foo/", Handlers.FooSlash); - - private static HttpResponse TextResponse(string body) => - new(200, HttpVersion.Http11, new List(), HttpBody.FromString(body)); -} -"#; - -const CS_FULL_URI_MODULE_CODE: &str = r#" -using System.Collections.Generic; -using SpacetimeDB; - -public static partial class Module -{ - [SpacetimeDB.HttpHandler] - public static HttpResponse EchoUri(HandlerContext ctx, HttpRequest request) - { - return new HttpResponse( - 200, - HttpVersion.Http11, - new List(), - HttpBody.FromString(request.Uri) - ); - } - - [SpacetimeDB.HttpRouter] - public static Router Router() => - SpacetimeDB.Router.New().Get("/echo-uri", Handlers.EchoUri); -} -"#; - -const CS_HANDLE_REQUEST_BODY_MODULE_CODE: &str = r#" -using System; -using System.Collections.Generic; -using System.Text; -using SpacetimeDB; - -public static partial class Module -{ - [SpacetimeDB.HttpHandler] - public static HttpResponse ReverseBytes(HandlerContext ctx, HttpRequest request) - { - var reversed = request.Body.ToBytes(); - Array.Reverse(reversed); - return BytesResponse(200, reversed); - } - - [SpacetimeDB.HttpHandler] - public static HttpResponse ReverseWords(HandlerContext ctx, HttpRequest request) - { - string body; - try - { - body = new UTF8Encoding(false, true).GetString(request.Body.ToBytes()); - } - catch (DecoderFallbackException) - { - return TextResponse(400, "request body must be valid UTF-8"); - } - - var reversed = string.Join(" ", body.Split(' ').Reverse()); - return TextResponse(200, reversed); - } - - [SpacetimeDB.HttpRouter] - public static Router Router() => - SpacetimeDB.Router.New() - .Post("/reverse-bytes", Handlers.ReverseBytes) - .Post("/reverse-words", Handlers.ReverseWords); - - private static HttpResponse BytesResponse(ushort statusCode, byte[] body) => - new(statusCode, HttpVersion.Http11, new List(), new HttpBody(body)); - - private static HttpResponse TextResponse(ushort statusCode, string body) => - new(statusCode, HttpVersion.Http11, new List(), HttpBody.FromString(body)); -} -"#; +use spacetimedb_smoketests::{allow_dotnet, random_string, Smoketest}; const NO_SUCH_ROUTE_BODY: &str = "Database has not registered a handler for this route"; -fn extract_code_blocks(doc_path: &Path, regex_src: &str, language_name: &str) -> String { - let doc = fs::read_to_string(doc_path).unwrap_or_else(|e| panic!("failed to read {}: {e}", doc_path.display())); - let doc = doc.replace("\r\n", "\n"); - - let re = Regex::new(regex_src).expect("regex should compile"); - let blocks: Vec<_> = re - .captures_iter(&doc) - .map(|cap| cap.get(1).expect("capture group should exist").as_str().to_string()) - .collect(); - - assert!( - !blocks.is_empty(), - "expected at least one {} code block in {}", - language_name, - doc_path.display() - ); - - blocks.join("\n\n") -} - fn rust_http_test(module: &str) -> (Smoketest, String) { let test = Smoketest::builder().precompiled_module(module).build(); let identity = test @@ -866,39 +12,22 @@ fn rust_http_test(module: &str) -> (Smoketest, String) { (test, identity) } -fn cpp_http_test(name: &str, module_code: &str) -> (Smoketest, String) { - require_emscripten!(); - let mut test = Smoketest::builder().autopublish(false).build(); - let identity = test - .publish() - .name(name) - .source(ModuleLanguage::Cpp, name, module_code) - .run() - .unwrap(); +fn cpp_http_test(name: &str) -> (Smoketest, String) { + let mut test = Smoketest::builder().precompiled_module(name).autopublish(false).build(); + let identity = test.publish().name(name).run().unwrap(); (test, identity) } -fn typescript_http_test(name: &str, module_code: &str) -> (Smoketest, String) { - require_pnpm!(); - let mut test = Smoketest::builder().autopublish(false).build(); +fn typescript_http_test(name: &str) -> (Smoketest, String) { + let mut test = Smoketest::builder().precompiled_module(name).autopublish(false).build(); let database_name = format!("{name}-{}", random_string()); - let identity = test - .publish() - .name(&database_name) - .source(ModuleLanguage::TypeScript, name, module_code) - .run() - .unwrap(); + let identity = test.publish().name(&database_name).run().unwrap(); (test, identity) } -fn csharp_http_test(name: &str, module_code: &str) -> (Smoketest, String) { - let mut test = Smoketest::builder().autopublish(false).build(); - let identity = test - .publish() - .name(name) - .source(ModuleLanguage::CSharp, name, module_code) - .run() - .unwrap(); +fn csharp_http_test(name: &str) -> (Smoketest, String) { + let mut test = Smoketest::builder().precompiled_module(name).autopublish(false).build(); + let identity = test.publish().name(name).run().unwrap(); (test, identity) } @@ -1162,128 +291,127 @@ fn handle_request_body() { #[test] fn cpp_http_routes_end_to_end() { - let (test, identity) = cpp_http_test("http-routes-cpp-basic", CPP_MODULE_CODE); + let (test, identity) = cpp_http_test("http-routes-cpp-basic"); assert_http_routes_end_to_end(&test.server_url, &identity); } #[test] fn typescript_http_routes_end_to_end() { - let (test, identity) = typescript_http_test("http-routes-typescript-basic", TS_MODULE_CODE); + let (test, identity) = typescript_http_test("http-routes-typescript-basic"); assert_http_routes_end_to_end(&test.server_url, &identity); } #[test] fn csharp_http_routes_end_to_end() { - require_dotnet!(); - let (test, identity) = csharp_http_test("http-routes-csharp-basic", CS_MODULE_CODE); + if !allow_dotnet() { + return; + } + let (test, identity) = csharp_http_test("http-routes-csharp-basic"); assert_http_routes_end_to_end(&test.server_url, &identity); } #[test] fn cpp_http_routes_pr_example_round_trip() { - let (test, identity) = cpp_http_test("http-routes-cpp-example", CPP_EXAMPLE_MODULE_CODE); + let (test, identity) = cpp_http_test("http-routes-cpp-example"); assert_http_routes_pr_example_round_trip(&test.server_url, &identity); } #[test] fn typescript_http_routes_pr_example_round_trip() { - let (test, identity) = typescript_http_test("http-routes-typescript-example", TS_EXAMPLE_MODULE_CODE); + let (test, identity) = typescript_http_test("http-routes-typescript-example"); assert_http_routes_pr_example_round_trip(&test.server_url, &identity); } #[test] fn csharp_http_routes_pr_example_round_trip() { - require_dotnet!(); - let (test, identity) = csharp_http_test("http-routes-csharp-example", CS_EXAMPLE_MODULE_CODE); + if !allow_dotnet() { + return; + } + let (test, identity) = csharp_http_test("http-routes-csharp-example"); assert_http_routes_pr_example_round_trip(&test.server_url, &identity); } #[test] fn cpp_http_routes_are_strict_for_non_root_paths() { - let (test, identity) = cpp_http_test( - "http-routes-cpp-strict-non-root", - CPP_STRICT_NON_ROOT_ROUTING_MODULE_CODE, - ); + let (test, identity) = cpp_http_test("http-routes-cpp-strict-non-root"); assert_http_routes_are_strict_for_non_root_paths(&test.server_url, &identity); } #[test] fn typescript_http_routes_are_strict_for_non_root_paths() { - let (test, identity) = typescript_http_test( - "http-routes-typescript-strict-non-root", - TS_STRICT_NON_ROOT_ROUTING_MODULE_CODE, - ); + let (test, identity) = typescript_http_test("http-routes-typescript-strict-non-root"); assert_http_routes_are_strict_for_non_root_paths(&test.server_url, &identity); } #[test] fn csharp_http_routes_are_strict_for_non_root_paths() { - require_dotnet!(); - let (test, identity) = csharp_http_test( - "http-routes-csharp-strict-non-root", - CS_STRICT_NON_ROOT_ROUTING_MODULE_CODE, - ); + if !allow_dotnet() { + return; + } + let (test, identity) = csharp_http_test("http-routes-csharp-strict-non-root"); assert_http_routes_are_strict_for_non_root_paths(&test.server_url, &identity); } #[test] fn cpp_http_routes_are_strict_for_root_paths() { - let (test, identity) = cpp_http_test("http-routes-cpp-strict-root", CPP_STRICT_ROOT_ROUTING_MODULE_CODE); + let (test, identity) = cpp_http_test("http-routes-cpp-strict-root"); assert_http_routes_are_strict_for_root_paths(&test.server_url, &identity); } #[test] fn typescript_http_routes_are_strict_for_root_paths() { - let (test, identity) = - typescript_http_test("http-routes-typescript-strict-root", TS_STRICT_ROOT_ROUTING_MODULE_CODE); + let (test, identity) = typescript_http_test("http-routes-typescript-strict-root"); assert_http_routes_are_strict_for_root_paths(&test.server_url, &identity); } #[test] fn csharp_http_routes_are_strict_for_root_paths() { - require_dotnet!(); - let (test, identity) = csharp_http_test("http-routes-csharp-strict-root", CS_STRICT_ROOT_ROUTING_MODULE_CODE); + if !allow_dotnet() { + return; + } + let (test, identity) = csharp_http_test("http-routes-csharp-strict-root"); assert_http_routes_are_strict_for_root_paths(&test.server_url, &identity); } #[test] fn cpp_http_handler_observes_full_external_uri() { - let (test, identity) = cpp_http_test("http-routes-cpp-full-uri", CPP_FULL_URI_MODULE_CODE); + let (test, identity) = cpp_http_test("http-routes-cpp-full-uri"); assert_http_handler_observes_full_external_uri(&test.server_url, &identity); } #[test] fn typescript_http_handler_observes_full_external_uri() { - let (test, identity) = typescript_http_test("http-routes-typescript-full-uri", TS_FULL_URI_MODULE_CODE); + let (test, identity) = typescript_http_test("http-routes-typescript-full-uri"); assert_http_handler_observes_full_external_uri(&test.server_url, &identity); } #[test] fn csharp_http_handler_observes_full_external_uri() { - require_dotnet!(); - let (test, identity) = csharp_http_test("http-routes-csharp-full-uri", CS_FULL_URI_MODULE_CODE); + if !allow_dotnet() { + return; + } + let (test, identity) = csharp_http_test("http-routes-csharp-full-uri"); assert_http_handler_observes_full_external_uri(&test.server_url, &identity); } #[test] fn cpp_handle_request_body() { - let (test, identity) = cpp_http_test("http-routes-cpp-request-body", CPP_HANDLE_REQUEST_BODY_MODULE_CODE); + let (test, identity) = cpp_http_test("http-routes-cpp-request-body"); assert_handle_request_body(&test.server_url, &identity); } #[test] fn typescript_handle_request_body() { - let (test, identity) = typescript_http_test( - "http-routes-typescript-request-body", - TS_HANDLE_REQUEST_BODY_MODULE_CODE, - ); + let (test, identity) = typescript_http_test("http-routes-typescript-request-body"); assert_handle_request_body(&test.server_url, &identity); } #[test] fn csharp_handle_request_body() { - require_dotnet!(); - let (test, identity) = csharp_http_test("http-routes-csharp-request-body", CS_HANDLE_REQUEST_BODY_MODULE_CODE); + if !allow_dotnet() { + return; + } + let (test, identity) = csharp_http_test("http-routes-csharp-request-body"); assert_handle_request_body(&test.server_url, &identity); } @@ -1306,20 +434,7 @@ fn http_handlers_tutorial_say_hello_route_works() { /// Validates the C++ example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. #[test] fn cpp_http_handlers_tutorial_say_hello_route_works() { - require_emscripten!(); - - let module_code = extract_code_blocks( - &workspace_root().join("docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md"), - r"```(?:cpp|c\+\+)\n([\s\S]*?)\n```", - "cpp", - ); - let mut test = Smoketest::builder().autopublish(false).build(); - let identity = test - .publish() - .name("http-handlers-docs-cpp") - .source(ModuleLanguage::Cpp, "http-handlers-docs-cpp", &module_code) - .run() - .unwrap(); + let (test, identity) = cpp_http_test("http-handlers-docs-cpp"); let url = format!("{}/v1/database/{identity}/route/say-hello", test.server_url); let client = reqwest::blocking::Client::new(); @@ -1332,24 +447,7 @@ fn cpp_http_handlers_tutorial_say_hello_route_works() { /// Validates the TypeScript example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. #[test] fn typescript_http_handlers_tutorial_say_hello_route_works() { - require_pnpm!(); - - let module_code = extract_code_blocks( - &workspace_root().join("docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md"), - r"```(?:ts|typescript)\n([\s\S]*?)\n```", - "typescript", - ); - let mut test = Smoketest::builder().autopublish(false).build(); - let identity = test - .publish() - .name("http-handlers-docs-typescript") - .source( - ModuleLanguage::TypeScript, - "http-handlers-docs-typescript", - &module_code, - ) - .run() - .unwrap(); + let (test, identity) = typescript_http_test("http-handlers-docs-typescript"); let url = format!("{}/v1/database/{identity}/route/say-hello", test.server_url); let client = reqwest::blocking::Client::new(); @@ -1362,13 +460,10 @@ fn typescript_http_handlers_tutorial_say_hello_route_works() { /// Validates the C# example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. #[test] fn csharp_http_handlers_tutorial_say_hello_route_works() { - require_dotnet!(); - let module_code = extract_code_blocks( - &workspace_root().join("docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md"), - r"```csharp\n([\s\S]*?)\n```", - "csharp", - ); - let (test, identity) = csharp_http_test("http-handlers-docs-csharp", &module_code); + if !allow_dotnet() { + return; + } + let (test, identity) = csharp_http_test("http-handlers-docs-csharp"); let url = format!("{}/v1/database/{}/route/say-hello", test.server_url, identity); let client = reqwest::blocking::Client::new(); diff --git a/crates/smoketests/tests/cluster/views.rs b/crates/smoketests/tests/cluster/views.rs index ae05a6e4d73..92ab74534db 100644 --- a/crates/smoketests/tests/cluster/views.rs +++ b/crates/smoketests/tests/cluster/views.rs @@ -1,184 +1,5 @@ use serde_json::{json, Value}; -use spacetimedb_smoketests::{random_string, require_dotnet, require_pnpm, ModuleLanguage, Smoketest}; - -const TS_VIEWS_SUBSCRIBE_MODULE: &str = r#"import { schema, t, table } from "spacetimedb/server"; - -const playerState = table( - { name: "player_state" }, - { - identity: t.identity().primaryKey(), - name: t.string().unique(), - online: t.bool(), - } -); - -const spacetimedb = schema({ playerState }); -export default spacetimedb; - -export const my_player = spacetimedb.view( - { public: true }, - t.option(playerState.rowType), - ctx => ctx.db.playerState.identity.find(ctx.sender) ?? undefined -); - -export const all_players = spacetimedb.anonymousView( - { public: true }, - t.array(playerState.rowType), - ctx => ctx.from.playerState -); - -export const online_players = spacetimedb.anonymousView( - { public: true }, - t.array(playerState.rowType), - ctx => ctx.from.playerState.where(row => row.online) -); - -export const insert_player_proc = spacetimedb.procedure( - { name: t.string() }, - t.unit(), - (ctx, { name }) => { - const sender = ctx.sender; - ctx.withTx(tx => { - tx.db.playerState.insert({ name, identity: sender, online: true }); - }); - return {}; - } -); -"#; - -const CS_VIEWS_QUERY_BUILDER_MODULE: &str = r#"using SpacetimeDB; - -public static partial class Module -{ - [Table(Accessor = "Table", Public = true)] - public partial struct Table - { - public uint Value; - public bool Alive; - } - - [Reducer] - public static void InsertValue(ReducerContext ctx, uint value, bool alive) - { - ctx.Db.Table.Insert(new Table { Value = value, Alive = alive }); - } - - [View(Accessor = "all", Public = true)] - public static IQuery
All(ViewContext ctx) - { - return ctx.From.Table(); - } - - [View(Accessor = "some", Public = true)] - public static IQuery
Some(ViewContext ctx) - { - return ctx.From.Table().Where(Row => Row.Alive); - } -} -"#; - -const CS_COUNT_VIEW_MODULE: &str = r#"using SpacetimeDB; - -[SpacetimeDB.Type] -public partial struct ItemCount -{ - public ulong count; -} - -public static partial class Module -{ - [Table(Accessor = "item", Public = true)] - public partial struct Item - { - [PrimaryKey] - public uint id; - public uint value; - } - - [View(Accessor = "sender_table_count", Public = true)] - public static ItemCount? sender_table_count(ViewContext ctx) - { - return new ItemCount { count = ctx.Db.item.Count }; - } - - [View(Accessor = "anon_table_count", Public = true)] - public static ItemCount? anon_table_count(AnonymousViewContext ctx) - { - return new ItemCount { count = ctx.Db.item.Count }; - } - - [Reducer] - public static void insert_item(ReducerContext ctx, uint id, uint value) - { - ctx.Db.item.Insert(new Item { id = id, value = value }); - } - - [Reducer] - public static void replace_item(ReducerContext ctx, uint id, uint value) - { - ctx.Db.item.id.Delete(id); - ctx.Db.item.Insert(new Item { id = id, value = value }); - } - - [Reducer] - public static void delete_item(ReducerContext ctx, uint id) - { - ctx.Db.item.id.Delete(id); - } -} -"#; - -const TS_COUNT_VIEW_MODULE: &str = r#"import { schema, t, table } from "spacetimedb/server"; - -const item = table( - { name: "item" }, - { - id: t.u32().primaryKey(), - value: t.u32(), - } -); - -const itemCount = t.object("ItemCountRow", { - count: t.u64(), -}); - -const spacetimedb = schema({ item }); -export default spacetimedb; - -export const sender_table_count = spacetimedb.view( - { public: true }, - t.option(itemCount), - ctx => ({ count: ctx.db.item.count() }) -); - -export const anon_table_count = spacetimedb.anonymousView( - { public: true }, - t.option(itemCount), - ctx => ({ count: ctx.db.item.count() }) -); - -export const insert_item = spacetimedb.reducer( - { id: t.u32(), value: t.u32() }, - (ctx, { id, value }) => { - ctx.db.item.insert({ id, value }); - } -); - -export const replace_item = spacetimedb.reducer( - { id: t.u32(), value: t.u32() }, - (ctx, { id, value }) => { - ctx.db.item.id.delete(id); - ctx.db.item.insert({ id, value }); - } -); - -export const delete_item = spacetimedb.reducer( - { id: t.u32() }, - (ctx, { id }) => { - ctx.db.item.id.delete(id); - } -); -"#; +use spacetimedb_smoketests::{allow_dotnet, random_string, Smoketest}; fn project_fields(events: Vec, view_name: &str, projected_fields: &[&str]) -> Vec { let project_row = |row: &Value| { @@ -762,18 +583,10 @@ fn test_procedure_triggers_subscription_updates() { #[test] fn test_typescript_procedure_triggers_subscription_updates() { - require_pnpm!(); let mut test = Smoketest::builder().autopublish(false).build(); let database_name = format!("views-subscribe-typescript-{}", random_string()); - test.publish() - .name(&database_name) - .source( - ModuleLanguage::TypeScript, - "views-subscribe-typescript", - TS_VIEWS_SUBSCRIBE_MODULE, - ) - .run() - .unwrap(); + test.use_precompiled_module("views-subscribe-typescript"); + test.publish().name(&database_name).run().unwrap(); let sub = test .subscribe(&["select * from my_player"]) @@ -800,33 +613,23 @@ fn test_rust_count_view_subscription_refreshes() { #[test] fn test_csharp_count_view_subscription_refreshes() { - require_dotnet!(); + if !allow_dotnet() { + return; + } let mut test = Smoketest::builder().autopublish(false).build(); - test.publish() - .name("views-count-csharp") - .source(ModuleLanguage::CSharp, "views-count-csharp", CS_COUNT_VIEW_MODULE) - .run() - .unwrap(); + test.use_precompiled_module("views-count-csharp"); + test.publish().name("views-count-csharp").run().unwrap(); assert_all_count_view_refreshes(&test); } #[test] fn test_typescript_count_view_subscription_refreshes() { - require_pnpm!(); - let mut test = Smoketest::builder().autopublish(false).build(); let database_name = format!("views-count-typescript-{}", random_string()); - test.publish() - .name(&database_name) - .source( - ModuleLanguage::TypeScript, - "views-count-typescript", - TS_COUNT_VIEW_MODULE, - ) - .run() - .unwrap(); + test.use_precompiled_module("views-count-typescript"); + test.publish().name(&database_name).run().unwrap(); assert_all_count_view_refreshes(&test); } @@ -914,18 +717,10 @@ fn test_disconnect_does_not_break_anonymous_view() { #[test] fn test_typescript_query_builder_view_query() { - require_pnpm!(); let mut test = Smoketest::builder().autopublish(false).build(); let database_name = format!("views-query-builder-typescript-{}", random_string()); - test.publish() - .name(&database_name) - .source( - ModuleLanguage::TypeScript, - "views-query-builder-typescript", - TS_VIEWS_SUBSCRIBE_MODULE, - ) - .run() - .unwrap(); + test.use_precompiled_module("views-subscribe-typescript"); + test.publish().name(&database_name).run().unwrap(); test.call("insert_player_proc", &["Alice"]).unwrap(); @@ -939,13 +734,12 @@ fn test_typescript_query_builder_view_query() { #[test] fn test_csharp_query_builder_view_query() { - require_dotnet!(); + if !allow_dotnet() { + return; + } let mut test = Smoketest::builder().autopublish(false).build(); - test.publish() - .name("views-csharp") - .source(ModuleLanguage::CSharp, "views-csharp", CS_VIEWS_QUERY_BUILDER_MODULE) - .run() - .unwrap(); + test.use_precompiled_module("views-csharp"); + test.publish().name("views-csharp").run().unwrap(); test.call("insert_value", &["0", "false"]).unwrap(); test.call("insert_value", &["1", "true"]).unwrap(); diff --git a/crates/smoketests/tests/standalone/change_host_type.rs b/crates/smoketests/tests/standalone/change_host_type.rs index 659da788b14..f2f3213a797 100644 --- a/crates/smoketests/tests/standalone/change_host_type.rs +++ b/crates/smoketests/tests/standalone/change_host_type.rs @@ -1,31 +1,13 @@ -use spacetimedb_smoketests::{require_local_server, require_pnpm, ModuleLanguage, Smoketest}; +use spacetimedb_smoketests::{require_local_server, Smoketest}; const WASM_HOST_TYPE: &str = "0"; const JS_HOST_TYPE: &str = "1"; -const TS_MODULE_BASIC: &str = r#"import { schema, t, table } from "spacetimedb/server"; - -const person = table( - { name: "person", public: true }, - { - id: t.u64().primaryKey().autoInc(), - name: t.string() - } -); -const spacetimedb = schema({ person }); -export default spacetimedb; - -export const add = spacetimedb.reducer({ name: t.string() }, (ctx, { name }) => { - ctx.db.person.insert({ id: 0n, name }); -}); -"#; - /// Tests that updating a module and also changing the host type works. /// /// Note that this test restarts the server. #[test] fn test_update_with_different_host_type() { - require_pnpm!(); require_local_server!(); const PERSON_A: &str = "Person A"; @@ -41,11 +23,8 @@ fn test_update_with_different_host_type() { add_person(&test, PERSON_A, "initial"); // Publish a TS module. - test.publish() - .name(&database_identity) - .source(ModuleLanguage::TypeScript, "modules-basic-ts", TS_MODULE_BASIC) - .run() - .unwrap(); + test.use_precompiled_module("modules-basic-ts"); + test.publish().name(&database_identity).run().unwrap(); add_person(&test, PERSON_B, "post module update"); // Restart and assert that the data is still there. @@ -53,6 +32,7 @@ fn test_update_with_different_host_type() { assert_has_rows(&test, &[PERSON_A, PERSON_B], "post restart"); // Change back to original module and assert that the data is still there. + test.use_precompiled_module("modules-basic"); test.publish().current_database().unwrap().run().unwrap(); add_person(&test, PERSON_C, "post revert"); @@ -99,16 +79,12 @@ fn assert_has_rows(test: &Smoketest, names: &[&str], context: &str) { /// This test restarts the server. #[test] fn test_repair_host_type() { - require_pnpm!(); require_local_server!(); let mut test = Smoketest::builder().autopublish(false).build(); - test.publish() - .name("basic-ts-change-host-type") - .source(ModuleLanguage::TypeScript, "modules-basic-ts", TS_MODULE_BASIC) - .run() - .unwrap(); + test.use_precompiled_module("modules-basic-ts"); + test.publish().name("basic-ts-change-host-type").run().unwrap(); assert_host_type(&test, JS_HOST_TYPE); // Set the program kind to the wrong value. test.sql_confirmed(&format!("update st_module set program_kind={WASM_HOST_TYPE}")) diff --git a/crates/smoketests/tests/standalone/typescript_index_source_name.rs b/crates/smoketests/tests/standalone/typescript_index_source_name.rs index f3608ccd339..db6945042af 100644 --- a/crates/smoketests/tests/standalone/typescript_index_source_name.rs +++ b/crates/smoketests/tests/standalone/typescript_index_source_name.rs @@ -1,137 +1,21 @@ -use spacetimedb_smoketests::{random_string, require_local_server, require_pnpm, ModuleLanguage, Smoketest}; - -const TYPESCRIPT_MODULE_V1: &str = r#"import { schema, table, t } from "spacetimedb/server"; - -const AppUsers = table( - { name: "users", public: false }, - { - id: t.u64().primaryKey().autoInc(), - name: t.string(), - emailAddress: t.string().index("btree"), - }, -); - -const spacetimedb = schema({ - AppUsers, -}); -export default spacetimedb; - -export const insert_user = spacetimedb.reducer( - { - name: t.string(), - emailAddress: t.string(), - }, - (ctx, { name, emailAddress }) => { - ctx.db.AppUsers.insert({ - id: 0n, - name, - emailAddress, - }); - }, -); -"#; - -const TYPESCRIPT_MODULE_WITH_NEW_COLUMNS: &str = r#"import { schema, table, t } from "spacetimedb/server"; - -const AppUsers = table( - { name: "users", public: false }, - { - id: t.u64().primaryKey().autoInc(), - name: t.string(), - emailAddress: t.string().index("btree"), - age: t.number().optional().default(undefined), - isActive: t.bool().default(false).index(), - }, -); - -const spacetimedb = schema({ - AppUsers, -}); -export default spacetimedb; - -export const find_user_by_email = spacetimedb.reducer( - { emailAddress: t.string() }, - (ctx, { emailAddress }) => { - let count = 0; - for (const _row of ctx.db.AppUsers.emailAddress.filter(emailAddress)) { - count += 1; - } - console.info(`matched ${count}`); - }, -); - -export const find_users_by_active_status = spacetimedb.reducer( - { isActive: t.bool() }, - (ctx, { isActive }) => { - let count = 0; - for (const _row of ctx.db.AppUsers.isActive.filter(isActive)) { - count += 1; - } - console.info(`matched active users ${count}`); - }, -); -"#; - -const TYPESCRIPT_MODULE_V2_RENAMED_ACCESSOR: &str = r#"import { schema, table, t } from "spacetimedb/server"; - -const renamedUsers = table( - { name: "users", public: false }, - { - id: t.u64().primaryKey().autoInc(), - name: t.string(), - emailAddress: t.string().index("btree"), - }, -); - -const spacetimedb = schema({ - renamedUsers, -}); -export default spacetimedb; - -export const find_user_by_email = spacetimedb.reducer( - { emailAddress: t.string() }, - (ctx, { emailAddress }) => { - let count = 0; - for (const _row of ctx.db.renamedUsers.emailAddress.filter(emailAddress)) { - count += 1; - } - console.info(`matched ${count}`); - }, -); -"#; +use spacetimedb_smoketests::{random_string, require_local_server, Smoketest}; #[test] fn test_typescript_add_optional_columns() { - require_pnpm!(); require_local_server!(); let mut test = Smoketest::builder().autopublish(false).build(); let module_name = format!("typescript-add-optional-columns-{}", random_string()); - let database_identity = test - .publish() - .name(&module_name) - .source( - ModuleLanguage::TypeScript, - "typescript-add-optional-columns-v1", - TYPESCRIPT_MODULE_V1, - ) - .run() - .unwrap(); + test.use_precompiled_module("typescript-add-optional-columns-v1"); + let database_identity = test.publish().name(&module_name).run().unwrap(); test.call("insert_user", &["Alice", "alice@example.com"]).unwrap(); test.restart_server(); - test.publish() - .name(&database_identity) - .source( - ModuleLanguage::TypeScript, - "typescript-add-optional-columns-v2", - TYPESCRIPT_MODULE_WITH_NEW_COLUMNS, - ) - .run() - .unwrap(); + test.use_precompiled_module("typescript-add-optional-columns-v2"); + test.publish().name(&database_identity).run().unwrap(); test.call("find_user_by_email", &["alice@example.com"]).unwrap(); test.call("find_users_by_active_status", &["false"]).unwrap(); @@ -139,34 +23,18 @@ fn test_typescript_add_optional_columns() { #[test] fn test_typescript_change_index_source_name() { - require_pnpm!(); require_local_server!(); let mut test = Smoketest::builder().autopublish(false).build(); let module_name = format!("typescript-change-source-name-{}", random_string()); - let database_identity = test - .publish() - .name(&module_name) - .source( - ModuleLanguage::TypeScript, - "typescript-change-source-name-v1", - TYPESCRIPT_MODULE_V1, - ) - .run() - .unwrap(); + test.use_precompiled_module("typescript-add-optional-columns-v1"); + let database_identity = test.publish().name(&module_name).run().unwrap(); test.call("insert_user", &["Alice", "alice@example.com"]).unwrap(); - test.publish() - .name(&database_identity) - .source( - ModuleLanguage::TypeScript, - "typescript-change-source-name-v2", - TYPESCRIPT_MODULE_V2_RENAMED_ACCESSOR, - ) - .run() - .unwrap(); + test.use_precompiled_module("typescript-change-source-name-v2"); + test.publish().name(&database_identity).run().unwrap(); test.call("find_user_by_email", &["alice@example.com"]).unwrap(); } diff --git a/tools/ci/commands/smoketests/Cargo.toml b/tools/ci/commands/smoketests/Cargo.toml index f161fe64586..788fa1d2292 100644 --- a/tools/ci/commands/smoketests/Cargo.toml +++ b/tools/ci/commands/smoketests/Cargo.toml @@ -9,4 +9,5 @@ duct.workspace = true clap.workspace = true tempfile.workspace = true spacetimedb-guard.workspace = true +spacetimedb-smoketests = { path = "../../../../crates/smoketests" } ci-common = { path = "../../common" } diff --git a/tools/ci/commands/smoketests/src/main.rs b/tools/ci/commands/smoketests/src/main.rs index 869a75532d2..f608b633177 100644 --- a/tools/ci/commands/smoketests/src/main.rs +++ b/tools/ci/commands/smoketests/src/main.rs @@ -3,6 +3,7 @@ use anyhow::{bail, ensure, Context, Result}; use clap::{Parser, Subcommand, ValueEnum}; use duct::cmd; use spacetimedb_guard::ensure_binaries_built; +use spacetimedb_smoketests::prepare::{dotnet_prepared, prepare_modules}; use std::env; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -73,7 +74,7 @@ impl SmoketestSuite { #[derive(Subcommand)] enum SmoketestCmd { - /// Local helper: only build binaries without running tests. + /// Local helper: build binaries and module fixtures without running tests. /// /// Use this before running `cargo test --all` to ensure binaries are built. Prepare, @@ -103,11 +104,15 @@ fn main() -> Result<()> { Some(SmoketestCmd::Prepare) => { build_cli()?; build_standalone()?; - eprintln!("Binaries ready. You can now run `cargo test --all`."); + build_precompiled_modules(args.dotnet, false)?; + eprintln!("Binaries and available module fixtures ready. You can now run `cargo test --all`."); Ok(()) } - Some(SmoketestCmd::Archive { archive_file }) => archive_smoketests(&archive_file, args.suite), - Some(SmoketestCmd::RunArchive { archive_file, args }) => run_smoketest_archive(&archive_file, args), + Some(SmoketestCmd::Archive { archive_file }) => archive_smoketests(&archive_file, args.suite, args.dotnet), + Some(SmoketestCmd::RunArchive { + archive_file, + args: runner_args, + }) => run_smoketest_archive(&archive_file, args.dotnet, runner_args), None => run_smoketest( args.server, args.dotnet, @@ -164,7 +169,7 @@ fn run_binary_build(mut cmd: Command, failure_message: &str) -> Result<()> { Ok(()) } -fn build_precompiled_modules() -> Result<()> { +fn build_precompiled_modules(dotnet: bool, require_toolchains: bool) -> Result<()> { let workspace_root = env::current_dir()?; let modules_dir = workspace_root.join("crates/smoketests/modules"); @@ -189,12 +194,13 @@ fn build_precompiled_modules() -> Result<()> { .status()?; ensure!(status.success(), "Failed to build pre-compiled modules"); + prepare_modules(&ensure_binaries_built(), dotnet, require_toolchains)?; eprintln!("Pre-compiled modules built.\n"); Ok(()) } -fn archive_smoketests(archive_file: &Path, suite: SmoketestSuite) -> Result<()> { - build_precompiled_modules()?; +fn archive_smoketests(archive_file: &Path, suite: SmoketestSuite, dotnet: bool) -> Result<()> { + build_precompiled_modules(dotnet, true)?; let status = Command::new("cargo") .args(["nextest", "archive", "--timings", "-p", "spacetimedb-smoketests"]) @@ -208,7 +214,7 @@ fn archive_smoketests(archive_file: &Path, suite: SmoketestSuite) -> Result<()> // TODO: Share smoketest setup and cleanup with `run_smoketest` so the archive // and local execution paths cannot drift. -fn run_smoketest_archive(archive_file: &Path, args: Vec) -> Result<()> { +fn run_smoketest_archive(archive_file: &Path, dotnet: bool, args: Vec) -> Result<()> { let workspace_root = env::current_dir()?; let archive_file = if archive_file.is_absolute() { archive_file.to_path_buf() @@ -222,7 +228,7 @@ fn run_smoketest_archive(archive_file: &Path, args: Vec) -> Result<()> { let base_config_path = base_config_dir.path().join("config.toml"); let mut cmd = Command::new("cargo"); - set_env(&mut cmd, None, true, false, &base_config_path); + set_env(&mut cmd, None, dotnet && dotnet_prepared(), false, &base_config_path); cmd.args(["nextest", "run", "--archive-file"]) .arg(archive_file) .args(["--workspace-remap"]) @@ -264,7 +270,7 @@ fn run_smoketest( } // 2. Build pre-compiled modules (this also warms the WASM dependency cache) - build_precompiled_modules()?; + build_precompiled_modules(dotnet, false)?; let cli_path = ensure_binaries_built(); let base_config_dir = prepare_base_config(&cli_path, server.as_deref(), auth_host)?;