Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,8 @@ jobs:
with:
global-json-file: global.json

# Node.js and pnpm are required for the TypeScript smoketests.
# Node.js and pnpm are required for the TypeScript smoketests. The template
# smoketests run the templates' module tests, whose test harness needs Node 22.
- name: Set up Node.js
uses: actions/setup-node@v4
with:
Expand All @@ -416,6 +417,33 @@ jobs:

- uses: ./.github/actions/setup-pnpm

# The templates' module tests load the TypeScript SDK's test datastore,
# which the template smoketests build with wasm-bindgen.
- name: Install wasm-bindgen CLI
shell: bash
run: |
REQUIRED_WASM_BINDGEN_VERSION="$(
awk '
$1 == "name" && $3 == "\"wasm-bindgen\"" { in_pkg = 1; next }
in_pkg && $1 == "version" {
gsub(/"/, "", $3);
print $3;
exit;
}
' Cargo.lock
)"
if [ -z "${REQUIRED_WASM_BINDGEN_VERSION}" ]; then
echo "Failed to determine wasm-bindgen version from Cargo.lock"
exit 1
fi

INSTALLED_WASM_BINDGEN_VERSION="$(wasm-bindgen --version 2>/dev/null | awk '{print $2}' || true)"
if [ "${INSTALLED_WASM_BINDGEN_VERSION}" != "${REQUIRED_WASM_BINDGEN_VERSION}" ]; then
cargo install --locked --force wasm-bindgen-cli --version "${REQUIRED_WASM_BINDGEN_VERSION}"
fi

wasm-bindgen --version

- name: Set up Bun for Windows compiler discovery smoketest
if: runner.os == 'Windows'
uses: oven-sh/setup-bun@v2
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions codex-plugin/plugins/spacetimedb/skills/concepts/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ SpacetimeDB is a relational database that is also a server. It lets you upload a

1. **Backend:** Define table(s) to store the data
2. **Backend:** Define reducer(s) to mutate the data
3. **Client:** Subscribe to the table(s)
4. **Client:** Call the reducer(s) from UI
5. **Client:** Render the data from the table(s)
3. **Backend:** Unit-test the reducer(s) with the in-process module test harness (TypeScript and Rust modules; see https://spacetimedb.com/docs/how-to/unit-testing)
4. **Client:** Subscribe to the table(s)
5. **Client:** Call the reducer(s) from UI
6. **Client:** Render the data from the table(s)

---

Expand Down
46 changes: 45 additions & 1 deletion codex-plugin/plugins/spacetimedb/skills/rust-server/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: rust-server
description: SpacetimeDB Rust server module SDK reference. Use when writing tables, reducers, or module logic in Rust.
description: SpacetimeDB Rust server module SDK reference. Use when writing or unit-testing tables, reducers, or module logic in Rust.
license: Apache-2.0
metadata:
author: clockworklabs
Expand Down Expand Up @@ -381,6 +381,50 @@ pub enum Status { Online, Away, Offline }
pub struct Point { x: f32, y: f32 }
```

## Testing

Unit-test reducers, procedures and views in-process with the module test harness (`spacetimedb` 2.12+). It runs the module against the real datastore, in memory, so constraints and rollback behave as in production, with no server and no publish. Full guide: https://spacetimedb.com/docs/how-to/unit-testing

Add a dev-dependency with the `test-utils` feature, at the same version as the normal dependency, and run tests natively with `cargo test` (not for a WebAssembly target):

```toml
[dev-dependencies]
spacetimedb = { version = "2.12.0", features = ["test-utils"] }
icu_calendar = ">=2.1, <2.2" # until #6317 is fixed: the test datastore needs 2.1.x
```

`TestContext::new()` discovers the module's tables and functions and creates a fresh database. Call reducers as ordinary functions inside `with_reducer_tx`:

```rust
#[cfg(test)]
mod tests {
use super::*;
use spacetimedb::test_utils::{TestAuth, TestContext};
use spacetimedb::ConnectionId;

fn alice() -> TestAuth {
TestAuth::from_jwt_payload(r#"{"iss":"test-issuer","sub":"alice"}"#, ConnectionId::from_u128(1))
.expect("valid test claims")
}

#[test]
fn creates_an_entity() {
let test = TestContext::new().expect("test database");
test.with_reducer_tx(alice(), |ctx| {
create_entity(ctx, "Alice".to_string());
Ok::<(), String>(())
})
.expect("reducer succeeds");
assert_eq!(test.db.entity().count(), 1);
}
}
```

- `with_reducer_tx(auth, body)` commits when `body` returns `Ok`, and rolls back on `Err` or panic. For a reducer returning `()`, call it and then return `Ok::<(), String>(())`.
- `TestAuth::from_jwt_payload(claims_json, connection_id)` calls as a client; the same `iss` and `sub` always give the same `ctx.sender()`. `TestAuth::internal()` calls as the database itself, as scheduled reducers are.
- `test.clock` controls `ctx.timestamp`; `test.rng` controls `ctx.random()` and `ctx.rng()`.
- Subscriptions, client connections and deployment still need a running server (`spacetime start`).

## Complete Example

```rust
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: typescript-server
description: SpacetimeDB TypeScript server module SDK reference. Use when writing tables, reducers, or module logic in TypeScript.
description: SpacetimeDB TypeScript server module SDK reference. Use when writing or unit-testing tables, reducers, or module logic in TypeScript.
license: Apache-2.0
metadata:
author: clockworklabs
Expand Down Expand Up @@ -439,3 +439,47 @@ export const routes = spacetimedb.httpRouter(new Router().get('/health', health)
```

Handlers are synchronous: return `SyncResponse` directly rather than marking the callback `async`. Pass the exported `httpHandler(...)` value to the router, not its raw callback. The router selects the path, while a handler reads request data with APIs such as `request.text()`; `Request` has no `path` property. A handler context does not expose `ctx.db`; use `ctx.withTx(tx => ...)` when a handler needs transactional database access.

## Testing

Unit-test reducers, procedures and views in-process with the module test harness (`spacetimedb` 2.12+). It runs the module against the real datastore, in memory, so constraints and rollback behave as in production, with no server and no publish. Full guide: https://spacetimedb.com/docs/how-to/unit-testing

In the module directory, install `vitest` as a dev dependency, set `"type": "module"` and add `"test": "vitest run"` to `package.json`. The Vitest plugin replaces the host's `spacetime:sys` imports:

```typescript
// vitest.config.ts
import { defineConfig, type Plugin } from 'vitest/config';
import { spacetimedbModuleTestPlugin } from 'spacetimedb/server/test-utils/vitest';

export default defineConfig({
plugins: [spacetimedbModuleTestPlugin() as unknown as Plugin],
test: { environment: 'node' },
});
```

Pass the default schema and all module exports to `createModuleTestHarness`; each call creates a fresh database. An exported reducer is callable as `reducer(ctx, args)` (`{}` for no arguments):

```typescript
// src/index.test.ts
import { expect, it } from 'vitest';
import { ConnectionId } from 'spacetimedb';
import { createModuleTestHarness, TestAuth } from 'spacetimedb/server/test-utils';
import spacetimedb, * as moduleExports from './index';

const alice = TestAuth.fromJwtPayload(
JSON.stringify({ iss: 'test-issuer', sub: 'alice' }),
new ConnectionId(1n)
);

it('adds a record', () => {
const test = createModuleTestHarness(spacetimedb, moduleExports);
test.withReducerTx(alice, ctx => moduleExports.addRecord(ctx, { value: 1 }));
expect(test.db.scoreRecord.count()).toBe(1n);
});
```

- `withReducerTx(auth, body)` commits when `body` returns and rolls back and rethrows when it throws. Keep `body` synchronous; catching an error inside it commits.
- `TestAuth.fromJwtPayload(claimsJson, connectionId)` calls as a client; the same `iss` and `sub` always give the same `ctx.sender`. `TestAuth.internal()` calls as the database itself, as scheduled reducers are.
- `test.clock.set(...)` / `test.clock.advance(...)` control `ctx.timestamp`; the `rngSeed` option controls `ctx.random`.
- `test.viewContext(auth)` calls views; `test.procedureContextBuilder(auth).http(responder)` stubs outbound HTTP for procedures.
- Subscriptions, client connections and deployment still need a running server (`spacetime start`).
8 changes: 8 additions & 0 deletions crates/cli/src/subcommands/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2044,10 +2044,18 @@ pub fn init_typescript_project(project_path: &Path) -> anyhow::Result<()> {
include_str!("../../../../templates/basic-ts/spacetimedb/tsconfig.json"),
"tsconfig.json",
),
(
include_str!("../../../../templates/basic-ts/spacetimedb/vitest.config.ts"),
"vitest.config.ts",
),
(
include_str!("../../../../templates/basic-ts/spacetimedb/src/index.ts"),
"src/index.ts",
),
(
include_str!("../../../../templates/basic-ts/spacetimedb/src/index.test.ts"),
"src/index.test.ts",
),
];

check_for_git();
Expand Down
59 changes: 54 additions & 5 deletions crates/smoketests/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,11 @@ fn run_cmd_inner(args: &[&str], cwd: &Path, stdin_input: Option<&str>) -> Result

/// Runs a `pnpm` command and returns stdout as a string.
pub fn pnpm(args: &[&str], cwd: &Path) -> Result<String> {
pnpm_with_env(args, cwd, &[])
}

/// Runs a `pnpm` command with extra environment variables and returns stdout as a string.
pub fn pnpm_with_env(args: &[&str], cwd: &Path, env: &[(&str, &str)]) -> Result<String> {
let pnpm_path = pnpm_path().context("Could not locate pnpm")?;
let minimum_release_age = pnpm_minimum_release_age()?;

Expand All @@ -395,6 +400,7 @@ pub fn pnpm(args: &[&str], cwd: &Path) -> Result<String> {
.args(args)
.current_dir(cwd)
.env("npm_config_minimum_release_age", minimum_release_age)
.envs(env.iter().copied())
.output()
.with_context(|| format!("Failed to spawn pnpm {}", args.join(" ")))?;

Expand All @@ -410,12 +416,52 @@ pub fn pnpm(args: &[&str], cwd: &Path) -> Result<String> {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}

/// Builds the local TypeScript bindings package.
/// Builds the local TypeScript bindings package, once per test run.
///
/// Tests run in parallel, and nextest runs each one in its own process. A build
/// wipes `dist/` first, which would pull the SDK out from under a test that is
/// using it, so builds take a file lock, and a stamp file records the run that
/// last built the SDK so later tests in the same run skip it.
pub fn build_typescript_sdk() -> Result<()> {
let workspace = workspace_root();
let ts_bindings = workspace.join("crates/bindings-typescript");
pnpm(&["--filter", "spacetimedb", "install"], &ts_bindings)?;
pnpm(&["build"], &ts_bindings)?;
let ts_bindings = workspace_root().join("crates/bindings-typescript");
once_per_test_run("typescript-sdk", || {
pnpm(&["--filter", "spacetimedb", "install"], &ts_bindings)?;
pnpm(&["build"], &ts_bindings)?;
Ok(())
})
}

/// Builds the local TypeScript bindings package with the test datastore that
/// modules' tests load, once per test run.
///
/// `pnpm build` doesn't produce the datastore, and generating it needs
/// wasm-bindgen, so only tests that run module tests should call this.
pub fn build_typescript_sdk_test_utils() -> Result<()> {
build_typescript_sdk()?;
let ts_bindings = workspace_root().join("crates/bindings-typescript");
once_per_test_run("typescript-sdk-test-utils", || {
pnpm(&["prepare:test-utils-wasm"], &ts_bindings)?;
Ok(())
})
}

/// Runs `step` at most once per test run, across all of its test processes.
///
/// Runs hold a lock on `<name>.lock` in the shared target dir while they check
/// and run the step, and `<name>.stamp` records the run that last completed it.
fn once_per_test_run(name: &str, step: impl FnOnce() -> Result<()>) -> Result<()> {
let stamp_path = shared_target_dir().join(format!("{name}.stamp"));
let lock = fs::File::create(shared_target_dir().join(format!("{name}.lock")))?;
lock.lock().with_context(|| format!("Failed to lock {name}"))?;

// nextest gives every test process the same run ID; `cargo test` runs tests as threads of one process.
let run_id = std::env::var("NEXTEST_RUN_ID").unwrap_or_else(|_| std::process::id().to_string());
if fs::read_to_string(&stamp_path).is_ok_and(|built| built == run_id) {
return Ok(());
}

step()?;
fs::write(&stamp_path, run_id)?;
Ok(())
}

Expand Down Expand Up @@ -1294,6 +1340,9 @@ impl Smoketest {

let module_path = module_root.join("spacetimedb");
fs::write(module_path.join("src/index.ts"), module_source).context("Failed to write TypeScript module code")?;
// The scaffold's test exercises the sample module we just replaced, and publish type-checks it.
fs::remove_file(module_path.join("src/index.test.ts"))
.context("Failed to remove the scaffold's module test")?;

build_typescript_sdk()?;
let _ = pnpm(&["uninstall", "spacetimedb"], &module_path);
Expand Down
4 changes: 4 additions & 0 deletions crates/smoketests/tests/cluster/quickstart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,10 @@ log = "0.4"
)?;
}
"typescript" => {
// The tutorial has the reader delete the scaffold's module test, which tests the sample
// module they replace.
fs::remove_file(server_path.join("src/index.test.ts"))?;

// Build and link the TypeScript SDK
build_typescript_sdk()?;

Expand Down
Loading
Loading