Skip to content

Commit e8a2d33

Browse files
C# smoketest for IQuery views (#4391)
# Description of Changes ~~Updates Roslyn codegen to handle `IQuery` return type for views.~~ ~~`IQuery` wasn't recognized before this change, which meant `.Build()` was still required for query builder views.~~ ~~Note, we currently support the old `Query` return type which means we still support `.Build()`. I'm not sure if this was intended when `IQuery` was originally introduced, so I'm maintaining support for it until I can determine otherwise. cc @cloutiertyler.~~ This is now a test only change. This patch introduces scaffolding for defining and running C# module smoketests. It also adds a new C# smoketest for an`IQuery` view. # API and ABI breaking changes None # Expected complexity level and risk 2 # Testing A testing only change.
1 parent 824c993 commit e8a2d33

3 files changed

Lines changed: 352 additions & 1 deletion

File tree

crates/smoketests/src/csharp.rs

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
use anyhow::{anyhow, bail, Context, Result};
2+
use serde_json::Value;
3+
use std::fs;
4+
use std::path::{Path, PathBuf};
5+
use std::sync::OnceLock;
6+
7+
const PACKAGE_PROJECTS: [(&str, &str); 2] = [
8+
("BSATN.Runtime", "SpacetimeDB.BSATN.Runtime"),
9+
("Runtime", "SpacetimeDB.Runtime"),
10+
];
11+
12+
const REQUIRED_RUNTIME_PACKAGES: [&str; 2] = ["SpacetimeDB.BSATN.Runtime", "SpacetimeDB.Runtime"];
13+
14+
#[derive(Debug)]
15+
struct CsharpBuildEnv {
16+
local_feed_dir: PathBuf,
17+
}
18+
19+
static CSHARP_WORKLOAD_READY: OnceLock<Result<(), anyhow::Error>> = OnceLock::new();
20+
static CSHARP_BUILD_ENV: OnceLock<Result<CsharpBuildEnv, anyhow::Error>> = OnceLock::new();
21+
22+
/// Normalizes a filesystem path for string-based comparisons in NuGet artifacts.
23+
///
24+
/// NuGet and `project.assets.json` can emit paths with platform-specific separators
25+
/// and optional trailing slashes; this keeps comparisons stable across hosts.
26+
fn normalize_path(path: &Path) -> String {
27+
path.display()
28+
.to_string()
29+
.replace('\\', "/")
30+
.trim_end_matches('/')
31+
.to_string()
32+
}
33+
34+
fn package_cache_contains_version(cache_root: &Path, package_id: &str, version: &str) -> bool {
35+
// NuGet usually stores package IDs lower-cased on disk.
36+
let expected = cache_root.join(package_id.to_ascii_lowercase()).join(version);
37+
if expected.exists() {
38+
return true;
39+
}
40+
let Ok(entries) = fs::read_dir(cache_root) else {
41+
return false;
42+
};
43+
entries.flatten().any(|entry| {
44+
entry.file_type().map(|ty| ty.is_dir()).unwrap_or(false)
45+
&& entry.file_name().to_string_lossy().eq_ignore_ascii_case(package_id)
46+
&& entry.path().join(version).exists()
47+
})
48+
}
49+
50+
/// Runs `dotnet` in a given working directory with error context suitable for tests.
51+
///
52+
/// This wrapper centralizes command construction so callers consistently include
53+
/// command + cwd details in failures.
54+
fn run_dotnet(args: &[&str], cwd: &Path) -> Result<String> {
55+
let mut cmd = Vec::with_capacity(args.len() + 1);
56+
cmd.push("dotnet");
57+
cmd.extend_from_slice(args);
58+
crate::run_cmd(&cmd, cwd).with_context(|| format!("dotnet {} failed in {}", args.join(" "), cwd.display()))
59+
}
60+
61+
/// Ensures the WASI workload required by C# module publishing is present.
62+
///
63+
/// We do a best-effort install first, then assert by reading `dotnet workload list`.
64+
/// Result is memoized for the process so repeated C# smoketests avoid redundant setup.
65+
fn ensure_wasi_workload() -> Result<()> {
66+
let _ = CSHARP_WORKLOAD_READY
67+
.get_or_init(|| {
68+
let workspace = crate::workspace_root();
69+
let modules_dir = workspace.join("modules");
70+
let _ = run_dotnet(
71+
&[
72+
"workload",
73+
"install",
74+
"wasi-experimental",
75+
"--skip-manifest-update",
76+
],
77+
&modules_dir,
78+
);
79+
let workloads = run_dotnet(&["workload", "list"], &modules_dir)?;
80+
if !workloads.contains("wasi-experimental") {
81+
bail!(
82+
"dotnet wasi-experimental workload is required but not installed.\n`dotnet workload list` output:\n{}",
83+
workloads
84+
);
85+
}
86+
Ok(())
87+
})
88+
.as_ref()
89+
.map_err(|err| anyhow!("{err:#}"))?;
90+
Ok(())
91+
}
92+
93+
/// Builds (once per process) a local, source-built NuGet feed for runtime packages.
94+
///
95+
/// This is a guardrail against stale binaries. Tests consume packages packed from the
96+
/// current checkout rather than whatever may exist in machine-global caches.
97+
fn ensure_local_feed() -> Result<&'static CsharpBuildEnv> {
98+
CSHARP_BUILD_ENV
99+
.get_or_init(|| {
100+
ensure_wasi_workload()?;
101+
102+
let workspace = crate::workspace_root();
103+
let bindings = workspace.join("crates/bindings-csharp");
104+
let local_feed_dir = workspace.join("target/smoketests-csharp/local-feed");
105+
if local_feed_dir.exists() {
106+
fs::remove_dir_all(&local_feed_dir)
107+
.with_context(|| format!("Failed to clear {}", local_feed_dir.display()))?;
108+
}
109+
fs::create_dir_all(&local_feed_dir)
110+
.with_context(|| format!("Failed to create {}", local_feed_dir.display()))?;
111+
let local_feed_dir_str = local_feed_dir
112+
.to_str()
113+
.context("Local C# NuGet feed path is not valid UTF-8")?;
114+
115+
for (project_dir, _) in PACKAGE_PROJECTS {
116+
run_dotnet(
117+
&["pack", "-c", "Release", "-o", local_feed_dir_str],
118+
&bindings.join(project_dir),
119+
)?;
120+
}
121+
122+
let feed_files = fs::read_dir(&local_feed_dir)
123+
.with_context(|| format!("Failed to inspect {}", local_feed_dir.display()))?
124+
.flatten()
125+
.filter_map(|entry| entry.file_name().into_string().ok())
126+
.collect::<Vec<_>>();
127+
128+
for (_, package_id) in PACKAGE_PROJECTS {
129+
let package_prefix = format!("{package_id}.");
130+
if !feed_files
131+
.iter()
132+
.any(|name| name.starts_with(&package_prefix) && name.ends_with(".nupkg"))
133+
{
134+
bail!(
135+
"Local feed at {} is missing package {}. Found files: {:?}",
136+
local_feed_dir.display(),
137+
package_id,
138+
feed_files
139+
);
140+
}
141+
}
142+
143+
Ok(CsharpBuildEnv { local_feed_dir })
144+
})
145+
.as_ref()
146+
.map_err(|err| anyhow!("{err:#}"))
147+
}
148+
149+
/// Prepares a generated C# module directory for deterministic restore/publish.
150+
///
151+
/// It writes a module-local `nuget.config` that:
152+
/// - isolates global package cache to `<module>/.nuget/packages`
153+
/// - routes `SpacetimeDB.*` resolution to the source-built local feed
154+
/// - still allows all other dependencies from nuget.org
155+
pub(crate) fn prepare_csharp_module(module_path: &Path) -> Result<()> {
156+
let env = ensure_local_feed()?;
157+
158+
let package_cache_dir = module_path.join(".nuget/packages");
159+
if package_cache_dir.exists() {
160+
fs::remove_dir_all(&package_cache_dir)
161+
.with_context(|| format!("Failed to clear {}", package_cache_dir.display()))?;
162+
}
163+
fs::create_dir_all(&package_cache_dir)
164+
.with_context(|| format!("Failed to create {}", package_cache_dir.display()))?;
165+
166+
let nuget_config = format!(
167+
r#"<?xml version="1.0" encoding="utf-8"?>
168+
<configuration>
169+
<config>
170+
<add key="globalPackagesFolder" value="{}" />
171+
</config>
172+
<packageSources>
173+
<clear />
174+
<add key="spacetimedb-local" value="{}" />
175+
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
176+
</packageSources>
177+
<packageSourceMapping>
178+
<packageSource key="spacetimedb-local">
179+
<package pattern="SpacetimeDB.*" />
180+
</packageSource>
181+
<packageSource key="nuget.org">
182+
<package pattern="*" />
183+
</packageSource>
184+
</packageSourceMapping>
185+
</configuration>
186+
"#,
187+
normalize_path(&package_cache_dir),
188+
normalize_path(&env.local_feed_dir),
189+
);
190+
191+
fs::write(module_path.join("nuget.config"), nuget_config)
192+
.with_context(|| format!("Failed to write {}", module_path.join("nuget.config").display()))?;
193+
Ok(())
194+
}
195+
196+
/// Verifies a C# module restore/publish used the intended local bindings.
197+
///
198+
/// We assert two invariants:
199+
/// - required `SpacetimeDB.*` runtime packages were resolved in `obj/project.assets.json`
200+
/// - those resolved package versions are present in the module-local package cache
201+
///
202+
/// Failing any of these means the smoketest may have used stale or external packages.
203+
pub(crate) fn verify_csharp_module_restore(module_path: &Path) -> Result<()> {
204+
let _ = ensure_local_feed()?;
205+
206+
let assets_path = module_path.join("obj").join("project.assets.json");
207+
let assets_text =
208+
fs::read_to_string(&assets_path).with_context(|| format!("Failed to read {}", assets_path.display()))?;
209+
let assets: Value =
210+
serde_json::from_str(&assets_text).with_context(|| format!("Failed to parse {}", assets_path.display()))?;
211+
212+
let libraries = assets
213+
.get("libraries")
214+
.and_then(Value::as_object)
215+
.context("project.assets.json missing libraries")?;
216+
let package_cache_dir = module_path.join(".nuget/packages");
217+
for package_id in REQUIRED_RUNTIME_PACKAGES {
218+
let package_key = libraries
219+
.keys()
220+
.find(|name| name.starts_with(&format!("{package_id}/")))
221+
.with_context(|| {
222+
format!(
223+
"project.assets.json did not resolve expected package `{package_id}`.\nresolved SpacetimeDB packages: {:?}",
224+
libraries
225+
.keys()
226+
.filter(|name| name.starts_with("SpacetimeDB."))
227+
.collect::<Vec<_>>()
228+
)
229+
})?;
230+
let (_, version) = package_key
231+
.split_once('/')
232+
.with_context(|| format!("Unexpected package key format in project.assets.json: `{package_key}`"))?;
233+
if !package_cache_contains_version(&package_cache_dir, package_id, version) {
234+
bail!(
235+
"Resolved package `{package_id}/{version}` was not found in module-local package cache {}",
236+
normalize_path(&package_cache_dir),
237+
);
238+
}
239+
}
240+
241+
Ok(())
242+
}

crates/smoketests/src/lib.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
//! }
5151
//! ```
5252
53+
mod csharp;
5354
pub mod modules;
5455

5556
use anyhow::{bail, Context, Result};
@@ -824,6 +825,56 @@ impl Smoketest {
824825
Ok(identity)
825826
}
826827

828+
/// Initializes, writes, and publishes a C# module from source.
829+
///
830+
/// The module is initialized at `<test_project_dir>/<project_dir_name>/spacetimedb`.
831+
/// On success this updates `self.database_identity`.
832+
pub fn publish_csharp_module_source(
833+
&mut self,
834+
project_dir_name: &str,
835+
module_name: &str,
836+
module_source: &str,
837+
) -> Result<String> {
838+
let module_root = self.project_dir.path().join(project_dir_name);
839+
let module_root_str = module_root.to_str().context("Invalid C# project path")?;
840+
self.spacetime(&[
841+
"init",
842+
"--non-interactive",
843+
"--lang",
844+
"csharp",
845+
"--project-path",
846+
module_root_str,
847+
module_name,
848+
])?;
849+
850+
let module_path = module_root.join("spacetimedb");
851+
fs::write(module_path.join("Lib.cs"), module_source).context("Failed to write C# module code")?;
852+
csharp::prepare_csharp_module(&module_path)?;
853+
854+
let module_path_str = module_path.to_str().context("Invalid C# module path")?;
855+
let publish_output = self.spacetime(&[
856+
"publish",
857+
"--server",
858+
&self.server_url,
859+
"--module-path",
860+
module_path_str,
861+
"--yes",
862+
"--clear-database",
863+
module_name,
864+
])?;
865+
csharp::verify_csharp_module_restore(&module_path)?;
866+
867+
let re = Regex::new(r"identity: ([0-9a-fA-F]+)").unwrap();
868+
let identity = re
869+
.captures(&publish_output)
870+
.and_then(|caps| caps.get(1))
871+
.map(|m| m.as_str().to_string())
872+
.context("Failed to parse database identity from publish output")?;
873+
self.database_identity = Some(identity.clone());
874+
875+
Ok(identity)
876+
}
877+
827878
/// Writes new module code to the project.
828879
///
829880
/// This switches from precompiled mode to runtime compilation mode.

crates/smoketests/tests/smoketests/views.rs

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use serde_json::json;
2-
use spacetimedb_smoketests::{require_pnpm, Smoketest};
2+
use spacetimedb_smoketests::{require_dotnet, require_pnpm, Smoketest};
33

44
const TS_VIEWS_SUBSCRIBE_MODULE: &str = r#"import { schema, t, table } from "spacetimedb/server";
55
@@ -39,6 +39,36 @@ export const insert_player_proc = spacetimedb.procedure(
3939
);
4040
"#;
4141

42+
const CS_VIEWS_QUERY_BUILDER_MODULE: &str = r#"using SpacetimeDB;
43+
44+
public static partial class Module
45+
{
46+
[Table(Accessor = "Table", Public = true)]
47+
public partial struct Table
48+
{
49+
public uint Value;
50+
}
51+
52+
[Reducer]
53+
public static void InsertValue(ReducerContext ctx, uint value)
54+
{
55+
ctx.Db.Table.Insert(new Table { Value = value });
56+
}
57+
58+
[View(Accessor = "all", Public = true)]
59+
public static IQuery<Table> All(ViewContext ctx)
60+
{
61+
return ctx.From.Table();
62+
}
63+
64+
[View(Accessor = "some", Public = true)]
65+
public static IQuery<Table> Some(ViewContext ctx)
66+
{
67+
return ctx.From.Table().Where(Row => Row.Value.Eq(1));
68+
}
69+
}
70+
"#;
71+
4272
/// Tests that views populate the st_view_* system tables
4373
#[test]
4474
fn test_st_view_tables() {
@@ -552,3 +582,31 @@ fn test_typescript_query_builder_view_query() {
552582
"Alice""#,
553583
);
554584
}
585+
586+
#[test]
587+
fn test_csharp_query_builder_view_query() {
588+
require_dotnet!();
589+
let mut test = Smoketest::builder().autopublish(false).build();
590+
test.publish_csharp_module_source("views-csharp", "views-csharp", CS_VIEWS_QUERY_BUILDER_MODULE)
591+
.unwrap();
592+
593+
test.call("insert_value", &["0"]).unwrap();
594+
test.call("insert_value", &["1"]).unwrap();
595+
test.call("insert_value", &["2"]).unwrap();
596+
597+
test.assert_sql(
598+
"SELECT * FROM all",
599+
r#" value
600+
-------
601+
0
602+
1
603+
2"#,
604+
);
605+
606+
test.assert_sql(
607+
"SELECT * FROM some",
608+
r#" value
609+
-------
610+
1"#,
611+
);
612+
}

0 commit comments

Comments
 (0)