C# SDK tests (#706)

* Add C# SDK tests

* Add memoization

* Increase timeout

* Mark module_bindings as LF

* Regenerate from Rust again

* Sort tables & reducers for determinism

* cargo fmt

* Lint & fmt fixups

* Lint fixups

* Allow dirs ending in .wasm
This commit is contained in:
Ingvar Stepanyan
2024-01-16 18:09:43 +00:00
committed by GitHub
parent fd6a5cecef
commit 79b2d04210
9 changed files with 1803 additions and 224 deletions
+1 -1
View File
@@ -1 +1 @@
**/module_bindings/** linguist-generated=true
**/module_bindings/** linguist-generated=true eol=lf
+7 -2
View File
@@ -271,8 +271,13 @@ public class Module : IIncrementalGenerator
tableNames.Combine(addReducers),
(context, tuple) =>
{
var tableNames = tuple.Left;
var addReducers = tuple.Right;
// Sort tables and reducers by name to match Rust behaviour.
// Not really important outside of testing, but for testing
// it matters because we commit module-bindings
// so they need to match 1:1 between different langs.
var tableNames = tuple.Left.Sort();
var addReducers = tuple.Right.Sort((a, b) => a.Name.CompareTo(b.Name));
// Don't generate the FFI boilerplate if there are no tables or reducers.
if (tableNames.IsEmpty && addReducers.IsEmpty)
return;
context.AddSource(
+5 -1
View File
@@ -137,7 +137,11 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
query_params.push(("trace_log", "true"));
}
let path_to_wasm = crate::tasks::build(path_to_project, skip_clippy, build_debug)?;
let path_to_wasm = if !path_to_project.is_dir() && path_to_project.extension().map_or(false, |ext| ext == "wasm") {
path_to_project.clone()
} else {
crate::tasks::build(path_to_project, skip_clippy, build_debug)?
};
let program_bytes = fs::read(path_to_wasm)?;
println!(
"Uploading to {} => {}",
+1 -1
View File
@@ -56,7 +56,7 @@ impl TestCounter {
let lock = self.inner.lock().expect("TestCounterInner Mutex is poisoned");
let (lock, timeout_result) = self
.wait_until_done
.wait_timeout_while(lock, Duration::from_secs(5), |inner| {
.wait_timeout_while(lock, Duration::from_secs(30), |inner| {
inner.outcomes.len() != inner.registered.len()
})
.expect("TestCounterInner Mutex is poisoned");
+135 -137
View File
@@ -1,141 +1,139 @@
use spacetimedb_testing::sdk::Test;
macro_rules! declare_tests_with_suffix {
($lang:ident, $suffix:literal) => {
mod $lang {
use spacetimedb_testing::sdk::Test;
const MODULE: &str = "sdk-test";
const CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/test-client");
const MODULE: &str = concat!("sdk-test", $suffix);
const CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/test-client");
fn make_test(subcommand: &str) -> Test {
Test::builder()
.with_name(subcommand)
.with_module(MODULE)
.with_client(CLIENT)
.with_language("rust")
.with_bindings_dir("src/module_bindings")
.with_compile_command("cargo build")
.with_run_command(format!("cargo run -- {}", subcommand))
.build()
fn make_test(subcommand: &str) -> Test {
Test::builder()
.with_name(subcommand)
.with_module(MODULE)
.with_client(CLIENT)
.with_language("rust")
.with_bindings_dir("src/module_bindings")
.with_compile_command("cargo build")
.with_run_command(format!("cargo run -- {}", subcommand))
.build()
}
#[test]
fn insert_primitive() {
make_test("insert_primitive").run();
}
#[test]
fn delete_primitive() {
make_test("delete_primitive").run();
}
#[test]
fn update_primitive() {
make_test("update_primitive").run();
}
#[test]
fn insert_identity() {
make_test("insert_identity").run();
}
#[test]
fn delete_identity() {
make_test("delete_identity").run();
}
#[test]
fn update_identity() {
make_test("delete_identity").run();
}
#[test]
fn insert_address() {
make_test("insert_address").run();
}
#[test]
fn delete_address() {
make_test("delete_address").run();
}
#[test]
fn update_address() {
make_test("delete_address").run();
}
#[test]
fn on_reducer() {
make_test("on_reducer").run();
}
#[test]
fn fail_reducer() {
make_test("fail_reducer").run();
}
#[test]
fn insert_vec() {
make_test("insert_vec").run();
}
#[test]
fn insert_simple_enum() {
make_test("insert_simple_enum").run();
}
#[test]
fn insert_enum_with_payload() {
make_test("insert_enum_with_payload").run();
}
#[test]
fn insert_long_table() {
make_test("insert_long_table").run();
}
#[test]
fn resubscribe() {
make_test("resubscribe").run();
}
#[test]
#[should_panic]
fn should_fail() {
make_test("should_fail").run();
}
#[test]
fn reauth() {
make_test("reauth_part_1").run();
make_test("reauth_part_2").run();
}
#[test]
fn reconnect_same_address() {
make_test("reconnect_same_address").run();
}
#[test]
fn connect_disconnect_callbacks() {
Test::builder()
.with_name(concat!("connect_disconnect_callback_", stringify!($lang)))
.with_module(concat!("sdk-test-connect-disconnect", $suffix))
.with_client(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/connect_disconnect_client"
))
.with_language("rust")
.with_bindings_dir("src/module_bindings")
.with_compile_command("cargo build")
.with_run_command("cargo run")
.build()
.run();
}
}
};
}
#[test]
fn insert_primitive() {
make_test("insert_primitive").run();
}
#[test]
fn delete_primitive() {
make_test("delete_primitive").run();
}
#[test]
fn update_primitive() {
make_test("update_primitive").run();
}
#[test]
fn insert_identity() {
make_test("insert_identity").run();
}
#[test]
fn delete_identity() {
make_test("delete_identity").run();
}
#[test]
fn update_identity() {
make_test("delete_identity").run();
}
#[test]
fn insert_address() {
make_test("insert_address").run();
}
#[test]
fn delete_address() {
make_test("delete_address").run();
}
#[test]
fn update_address() {
make_test("delete_address").run();
}
#[test]
fn on_reducer() {
make_test("on_reducer").run();
}
#[test]
fn fail_reducer() {
make_test("fail_reducer").run();
}
#[test]
fn insert_vec() {
make_test("insert_vec").run();
}
#[test]
fn insert_simple_enum() {
make_test("insert_simple_enum").run();
}
#[test]
fn insert_enum_with_payload() {
make_test("insert_enum_with_payload").run();
}
#[test]
fn insert_long_table() {
make_test("insert_long_table").run();
}
#[test]
fn resubscribe() {
make_test("resubscribe").run();
}
#[test]
#[should_panic]
fn should_fail() {
make_test("should_fail").run();
}
#[test]
fn reauth() {
make_test("reauth_part_1").run();
make_test("reauth_part_2").run();
}
#[test]
fn reconnect_same_address() {
make_test("reconnect_same_address").run();
}
#[test]
fn connect_disconnect_callbacks() {
Test::builder()
.with_name("connect_disconnect_callback")
.with_module("sdk-test-connect-disconnect")
.with_client(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/connect_disconnect_client"))
.with_language("rust")
.with_bindings_dir("src/module_bindings")
.with_compile_command("cargo build")
.with_run_command("cargo run")
.build()
.run();
}
#[test]
fn connect_disconnect_callbacks_csharp() {
Test::builder()
.with_name("connect_disconnect_callback_csharp")
.with_module("sdk-test-connect-disconnect-cs")
.with_client(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/connect_disconnect_client"))
.with_language("rust")
.with_bindings_dir("src/module_bindings")
.with_compile_command("cargo build")
.with_run_command("cargo run")
.build()
.run();
}
declare_tests_with_suffix!(rust, "");
declare_tests_with_suffix!(csharp, "-cs");
+112 -82
View File
@@ -1,12 +1,13 @@
use duct::cmd;
use lazy_static::lazy_static;
use rand::distributions::{Alphanumeric, DistString};
use std::collections::HashMap;
use std::fs::create_dir_all;
use std::sync::Mutex;
use std::thread::JoinHandle;
use std::{collections::HashSet, fs::create_dir_all, sync::Mutex};
use crate::invoke_cli;
use crate::modules::{module_path, CompilationMode, CompiledModule};
use std::path::Path;
use crate::modules::{CompilationMode, CompiledModule};
use tempfile::TempDir;
pub fn ensure_standalone_process() {
@@ -34,41 +35,9 @@ pub fn ensure_standalone_process() {
}
}
lazy_static! {
/// An exclusive lock which ensures we only run `spacetime generate` once for each target directory.
///
/// Without this lock, if multiple `Test`s ran concurrently in the same process
/// with the same `client_project` and `generate_subdir`,
/// the test harness would run `spacetime generate` multiple times concurrently,
/// each of which would remove and re-populate the bindings directory,
/// potentially sweeping them out from under a compile or run process.
///
/// This lock ensures that only one `spacetime generate` process runs at a time,
/// and the `HashSet` ensures that we run `spacetime generate` only once for each output directory.
///
/// Circumstances where this will still break:
/// - If multiple tests want to use the same client_project/generate_subdir pair,
/// but for different modules' bindings, only one module's bindings will ever be generated.
/// If you need bindings for multiple different modules, put them in different subdirs.
/// - If multiple distinct test harness processes run concurrently,
/// they will encounter the race condition described above,
/// because the `BINDINGS_GENERATED` lock is not shared between harness processes.
/// Running multiple test harness processes concurrently will break anyways
/// because each will try to run `spacetime start` as a subprocess and will therefore
/// contend over port 3000.
/// Prefer constructing multiple `Test`s and `Test::run`ing them
/// from within the same harness process.
//
// I (pgoldman 2023-09-11) considered, as an alternative to this lock,
// having `Test::run` copy the `client_project` into a fresh temporary directory.
// That would be more complicated, as we'd need to re-write dependencies
// on the client language's SpacetimeDB SDK to use a local absolute path.
// Doing so portably across all our SDK languages seemed infeasible.
static ref BINDINGS_GENERATED: Mutex<HashSet<String>> = Mutex::new(HashSet::new());
}
pub struct Test {
/// A human-readable name for this test.
#[allow(dead_code)] // TODO: should we just remove this now that it's unused?
name: String,
/// Must name a module in the SpacetimeDB/modules directory.
@@ -107,23 +76,23 @@ impl Test {
pub fn builder() -> TestBuilder {
TestBuilder::default()
}
pub fn run(&self) {
pub fn run(self) {
ensure_standalone_process();
let compiled = CompiledModule::compile(&self.module_name, CompilationMode::Debug);
let wasm_file = compile_module(&self.module_name);
generate_bindings(
&self.generate_language,
compiled.path(),
&wasm_file,
&self.client_project,
&self.generate_subdir,
);
compile_client(&self.compile_command, &self.client_project, &self.name);
compile_client(&self.compile_command, &self.client_project);
let db_name = publish_module(&self.module_name);
let db_name = publish_module(&wasm_file);
run_client(&self.run_command, &self.client_project, &db_name, &self.name);
run_client(&self.run_command, &self.client_project, &db_name);
}
}
@@ -143,43 +112,96 @@ fn random_module_name() -> String {
Alphanumeric.sample_string(&mut rand::thread_rng(), 16)
}
fn publish_module(module: &str) -> String {
macro_rules! memoized {
(|$key:ident: $key_ty:ty| -> $value_ty:ty $body:block) => {{
static MEMOIZED: Mutex<Option<HashMap<$key_ty, $value_ty>>> = Mutex::new(None);
MEMOIZED
.lock()
.unwrap()
.get_or_insert_with(HashMap::new)
.entry($key)
.or_insert_with_key(|$key| -> $value_ty { $body })
.clone()
}};
}
// Note: this function is memoized to ensure we compile each module only once.
// Without this lock, if multiple `Test`s ran concurrently in the same process,
// the test harness would compile each module multiple times concurrently,
// which is bad both for performance reasons as well as can lead to errors
// with toolchains like .NET which don't expect parallel invocations
// of their build tools on the same project folder.
fn compile_module(module: &str) -> String {
let module = module.to_owned();
memoized!(|module: String| -> String {
let module = CompiledModule::compile(module, CompilationMode::Debug);
module.path().to_str().unwrap().to_owned()
})
}
// Note: this function does not memoize because we want each test to publish the same
// module as a separate clean database instance for isolation purposes.
fn publish_module(wasm_file: &str) -> String {
let name = random_module_name();
invoke_cli(&[
"publish",
"--debug",
"--project-path",
module_path(module).to_str().unwrap(),
wasm_file,
"--skip_clippy",
&name,
]);
name
}
fn generate_bindings(language: &str, path: &Path, client_project: &str, generate_subdir: &str) {
let generate_dir = format!("{}/{}", client_project, generate_subdir);
/// Note: this function is memoized to ensure we only run `spacetime generate` once for each target directory.
///
/// Without this lock, if multiple `Test`s ran concurrently in the same process
/// with the same `client_project` and `generate_subdir`,
/// the test harness would run `spacetime generate` multiple times concurrently,
/// each of which would remove and re-populate the bindings directory,
/// potentially sweeping them out from under a compile or run process.
///
/// This lock ensures that only one `spacetime generate` process runs at a time,
/// and the `HashSet` ensures that we run `spacetime generate` only once for each output directory.
///
/// Circumstances where this will still break:
/// - If multiple tests want to use the same client_project/generate_subdir pair,
/// but for different modules' bindings, only one module's bindings will ever be generated.
/// If you need bindings for multiple different modules, put them in different subdirs.
/// - If multiple distinct test harness processes run concurrently,
/// they will encounter the race condition described above,
/// because the `BINDINGS_GENERATED` lock is not shared between harness processes.
/// Running multiple test harness processes concurrently will break anyways
/// because each will try to run `spacetime start` as a subprocess and will therefore
/// contend over port 3000.
/// Prefer constructing multiple `Test`s and `Test::run`ing them
/// from within the same harness process.
//
// I (pgoldman 2023-09-11) considered, as an alternative to this lock,
// having `Test::run` copy the `client_project` into a fresh temporary directory.
// That would be more complicated, as we'd need to re-write dependencies
// on the client language's SpacetimeDB SDK to use a local absolute path.
// Doing so portably across all our SDK languages seemed infeasible.
fn generate_bindings(language: &str, wasm_file: &str, client_project: &str, generate_subdir: &str) {
let generate_dir = format!("{client_project}/{generate_subdir}");
let mut bindings_lock = BINDINGS_GENERATED.lock().expect("BINDINGS_GENERATED Mutex is poisoned");
// If we've already generated bindings in this directory,
// return early.
// Otherwise, we'll hold the lock for the duration of the subprocess,
// so other tests will wait before overwriting our output.
if !bindings_lock.insert(generate_dir.clone()) {
return;
}
create_dir_all(&generate_dir).expect("Error creating generate subdir");
invoke_cli(&[
"generate",
"--skip_clippy",
"--lang",
language,
"--wasm-file",
path.to_str().unwrap(),
"--out-dir",
&generate_dir,
]);
memoized!(|generate_dir: String| -> () {
create_dir_all(generate_dir).expect("Error creating generate subdir");
invoke_cli(&[
"generate",
"--debug",
"--skip_clippy",
"--lang",
language,
"--wasm-file",
wasm_file,
"--out-dir",
&generate_dir,
]);
})
}
fn split_command_string(command: &str) -> (&str, Vec<&str>) {
@@ -189,36 +211,44 @@ fn split_command_string(command: &str) -> (&str, Vec<&str>) {
(exe, args)
}
fn compile_client(compile_command: &str, client_project: &str, test_name: &str) {
let (exe, args) = split_command_string(compile_command);
// Note: this function is memoized to ensure we only compile each client once.
fn compile_client(compile_command: &str, client_project: &str) {
let client_project = client_project.to_owned();
let output = cmd(exe, args)
.dir(client_project)
.env(TEST_CLIENT_PROJECT_ENV_VAR, client_project)
.stderr_to_stdout()
.stdout_capture()
.unchecked()
.run()
.expect("Error running compile command");
memoized!(|client_project: String| -> () {
let (exe, args) = split_command_string(compile_command);
status_ok_or_panic(output, compile_command, test_name);
let output = cmd(exe, args)
.dir(client_project)
.env(TEST_CLIENT_PROJECT_ENV_VAR, client_project)
.stderr_to_stdout()
.stdout_capture()
.unchecked()
.run()
.expect("Error running compile command");
status_ok_or_panic(output, compile_command, "(compiling)");
})
}
fn run_client(run_command: &str, client_project: &str, db_name: &str, test_name: &str) {
fn run_client(run_command: &str, client_project: &str, db_name: &str) {
let (exe, args) = split_command_string(run_command);
let output = cmd(exe, args)
.dir(client_project)
.env(TEST_CLIENT_PROJECT_ENV_VAR, client_project)
.env(TEST_DB_NAME_ENV_VAR, db_name)
.env("RUST_LOG", "trace")
.env(
"RUST_LOG",
"spacetimedb=debug,spacetimedb_client_api=debug,spacetimedb_lib=debug,spacetimedb_standalone=debug",
)
.stderr_to_stdout()
.stdout_capture()
.unchecked()
.run()
.expect("Error running run command");
status_ok_or_panic(output, run_command, test_name);
status_ok_or_panic(output, run_command, "(running)");
}
#[derive(Clone, Default)]
+2
View File
@@ -0,0 +1,2 @@
bin
obj
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- needs to be exe for initializers to be embedded correctly -->
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>wasi-wasm</RuntimeIdentifier>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<WasmDebugLevel>1</WasmDebugLevel>
<WasmBuildNative>true</WasmBuildNative>
<WasmNativeDebugSymbols>true</WasmNativeDebugSymbols>
<WasmNativeStrip>false</WasmNativeStrip>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>
<!--
Use local package sources instead of published ones.
This makes integration test somewhat differ from production configuration, but
at least it simplifies workflow for editing and testing C# code itself.
-->
<ItemGroup>
<ProjectReference Include="../../crates/bindings-csharp/Codegen/Codegen.csproj" OutputItemType="Analyzer" />
<ProjectReference Include="../../crates/bindings-csharp/Runtime/Runtime.csproj" />
</ItemGroup>
</Project>