Testing
UrsaMU uses Deno’s built-in test runner.
The test suite has 58 test files covering authentication, command parsing, scripting, plugin
lifecycle, WebSocket rate limiting, scene export, and more.
Running Tests
# Full test suite
deno task test
# With LCOV coverage report
deno task test:coverage
# Single file
deno test --allow-all --unstable-kv --no-check tests/auth.test.ts
# Filter by test name
deno test --allow-all --unstable-kv --no-check --filter "auth login"
Always use
--no-check— the suite is large and type-checking adds significant overhead
with no benefit for test runs.
Test Structure
tests/
├── auth.test.ts # JWT auth, login, rate limiting
├── gameclock.test.ts # In-game calendar & time
├── plugin_deps.test.ts # Plugin loading & manifest
├── queue_scene_discord.test.ts# Async queue, scenes, Discord hooks
├── scripts_attrs.test.ts # @set, @examine, @describe, @flags, @name
├── scripts_comms.test.ts # say, pose, who, score
├── scripts_identity.test.ts # connect, create, quit
├── scripts_interaction.test.ts# drop, give, get, trigger
├── scripts_world.test.ts # look, dig, go (exit matching)
├── websocket_e2e.test.ts # Lifecycle, rate limit, broadcast, disconnect
└── …
Required Test Options
When a test imports anything from the service layer (e.g. cmdParser, DBO, sandboxService),
use these options to suppress Deno’s resource-leak and pending-op warnings:
const OPTS = {
sanitizeResources: false,
sanitizeOps: false,
};
Deno.test({ name: "my test", ...OPTS, fn: async () => { … } });
cmdParser triggers async file reads at init and never fully closes them, which would
otherwise cause every test that imports it to fail sanitization.
Testing System Scripts
System scripts (system/scripts/*.ts) are TypeScript files that default-export an
async function (u: IUrsamuSDK) => void. They can be tested directly by importing
them and calling them with a mock SDK object.
Basic pattern
import script from "../system/scripts/say.ts";
Deno.test({ name: "say", ...OPTS, fn: async () => {
let sent = "";
let broadcast = "";
const u = {
me: { id: "p1", name: "Alice", flags: new Set(["connected"]), state: {}, contents: [] },
cmd: { args: ["Hello world"], switches: [] },
send: (msg: string) => { sent += msg; },
emit: (msg: string) => { broadcast += msg; },
// … add other stubs as needed
} as any;
await script(u);
if (!sent.includes("You say")) throw new Error("Missing 'You say' echo");
if (!broadcast.includes("Alice says")) throw new Error("Missing broadcast");
}});
wrapScript — running scripts as legacy blocks
When a script needs access to the full Sandbox (e.g. scripts that call u.db.*
or u.ui.layout()), use wrapScript to strip the import/export declarations
and run the script as a legacy block inside the Sandbox service:
import { sandboxService } from "../src/services/Sandbox/SandboxService.ts";
function wrapScript(content: string): string {
return content
.replace(/^import\s.*?;?\s*$/gm, "")
.replace(/^export\s+default\s+/m, "const _main = ")
.replace(/^export\s+const\s+/gm, "const ")
+ "\nawait _main(u);";
}
const raw = await Deno.readTextFile("system/scripts/look.ts");
const result = await sandboxService.runScript(wrapScript(raw), {
id: "actor1",
me: { id: "actor1", name: "Bob", flags: new Set(["connected"]), state: {}, contents: [] },
here: { id: "room1", name: "Lobby", flags: new Set(["room"]), state: {}, contents: [] },
location: "room1",
state: {},
socketId: "sock1",
});
Scripts that emit u.ui.layout()
Scripts like who and score call u.ui.layout() which posts a type: "result" message
that resolves the sandbox early. Use an extended stub that captures both _sent and
_broadcast and stubs u.ui.layout:
const extra = `
const _sent = [];
const _broadcast = [];
u.send = (msg) => { _sent.push(msg); };
u.emit = (msg) => { _broadcast.push(msg); };
u.ui = { layout: (data) => { postMessage({ type: "result", data }); } };
`;
Testing Commands Registered with addCmd()
For commands registered via addCmd(), import the command module directly,
call the exec function, and inspect the mock SDK:
import { addCmd, cmds } from "../src/services/commands/cmdParser.ts";
Deno.test({ name: "greet command", ...OPTS, fn: async () => {
// Register the command
addCmd({
name: "greet",
pattern: /^greet\s+(.+)/i,
exec: async (u) => {
u.send(`You wave at ${u.cmd.args[0]}.`);
},
});
let output = "";
const u = {
me: { id: "p1", name: "Alice", flags: new Set(["connected"]), state: {}, contents: [] },
cmd: { args: ["Bob"], switches: [], original: "greet Bob" },
send: (msg: string) => { output += msg; },
emit: () => {},
} as any;
const cmd = cmds.find(c => c.name === "greet")!;
await cmd.exec(u);
if (!output.includes("wave at Bob")) throw new Error(output);
}});
Testing Plugins
Test a plugin’s init() and remove() in isolation by calling them directly.
Use a shared DB prefix that won’t collide with other tests.
import myPlugin from "../src/plugins/my-feature/index.ts";
import { DBO } from "../src/services/Database/index.ts";
const OPTS = { sanitizeResources: false, sanitizeOps: false };
const db = new DBO<{ title: string }>("test.my-feature");
Deno.test({ name: "my-feature plugin", ...OPTS, fn: async (t) => {
await myPlugin.init();
await t.step("creates a record", async () => {
await db.create({ title: "hello" });
const result = await db.queryOne({ title: "hello" });
if (!result || result.title !== "hello") throw new Error("Record not found");
});
// Always clean up
await myPlugin.remove?.();
await db.deleteAll({});
}});
Database IDs
Prefix DB IDs in tests to avoid collisions between test files that run in parallel:
// Good — unique prefix per test file
const actor = { id: "sa_actor1", … }; // "sa_" = scripts_attrs
const room = { id: "sa_room1", … };
// Bad — generic IDs collide across test files
const actor = { id: "actor1", … };
Close the DB in the last test of the file:
import { DBO as _DBO } from "../src/services/Database/index.ts";
// Last test in the file:
Deno.test({ name: "cleanup", ...OPTS, fn: async () => {
await _DBO.close();
}});
Stubbing the Sandbox
Scripts that reference in-game objects (exits, inventory, rooms) need the sandbox
stubs set up with inline JavaScript object literals — not JSON.stringify, which
breaks Set:
const jsExit = `{ id: "x1", name: "North", flags: new Set(["exit"]), state: {}, contents: [] }`;
// Counter must be defined INSIDE the extra string so it's scoped to the worker:
const extra = `
let _callCount = 0;
u.db = {
...u.db,
search: async () => ++_callCount === 1 ? [${jsExit}] : [],
};
`;
Coverage
deno task test:coverage
This runs the full suite, then outputs:
- An LCOV file at
coverage/lcov.info— compatible with most CI coverage dashboards - A summary table in the terminal
To view HTML coverage (requires genhtml from lcov):
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html
CI
The project uses GitHub Actions. The workflow is at .github/workflows/ci.yml.
Tests run on every push and pull request:
- name: Run tests
run: deno test --allow-all --unstable-kv --no-check
Tests must pass before a PR can be merged.
Known Pre-existing Failures
A small set of tests have pre-existing failures inherited from earlier versions.
These are not introduced by new code — they reflect commands that were
partially extracted to plugins. If you see failures in any of these, skip them:
@set, @examine, @describe, @flags, @name, @moniker, @alias,
@drop, @give, @trigger, @get, @chown, target(), Core Migration: flags
Run with --filter to exclude them while working on unrelated code:
deno test --allow-all --unstable-kv --no-check \
--filter "^(?!.*(examine|describe|@flags|moniker|alias|chown)).*"