npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@rhost/testkit

v1.4.0

Published

SDK for programmatically testing MUSHcode against a RhostMUSH server

Readme

@rhost/testkit

npm version npm downloads License: MIT CI Security

A Jest-like testing framework for RhostMUSH softcode. Write tests that run directly against a real MUSH server — local, CI container, or remote.

npm install @rhost/testkit

Contents


What it does

@rhost/testkit gives you a full test-runner loop for MUSHcode:

  • Eval softcode expressions and capture their output
  • Assert results with a Jest-like expect() API and MUSH-aware matchers
  • Snapshot test softcode output — first run writes, subsequent runs compare with a diff on mismatch
  • Preview raw server output exactly as a MUSH client sees it — ANSI colours and all
  • Manage fixtures — create/destroy objects, set attributes, force commands, send mail, and more
  • Validate softcode offline — catch syntax errors, wrong arg counts, register clobber risks, and dialect compatibility without a server
  • Format softcode — normalize whitespace and optionally indent nested calls (rhost-testkit fmt)
  • Benchmark expressions — measure median / p95 / p99 latency per softcode call against a live server
  • Watch mode — re-run tests on save with a 300ms debounce
  • Generate CI/CD workflows — one command to get GitHub Actions or GitLab CI configured
  • Spin up RhostMUSH in Docker for isolated, reproducible CI runs
  • Report results with a pretty, indented pass/fail tree

Installation

npm install @rhost/testkit

Peer requirements:

  • Node.js ≥ 18
  • Docker (only for RhostContainer — not required when connecting to an existing server)
  • TypeScript ≥ 5.0 (if using TypeScript)

How the runner works

RhostRunner uses a two-phase collect → run model identical to Jest:

Phase 1 — collect:  runner.describe() / runner.describe.skip() calls build a tree in memory.
Phase 2 — run:      runner.run(options) connects to the MUSH server and executes the tree.

The describe() callback runs synchronously during collection. The it() callback runs asynchronously during execution. All await calls belong inside it() callbacks, not inside describe() callbacks.

import { RhostRunner } from '@rhost/testkit';

const runner = new RhostRunner();

// Phase 1 — synchronous collection
runner.describe('add()', ({ it }) => {
    it('adds two numbers', async ({ expect }) => {
        await expect('add(2,3)').toBe('5');
    });
    it('handles negatives', async ({ expect }) => {
        await expect('add(-1,1)').toBe('0');
    });
});

// Phase 2 — async execution (require explicit password — no fallback)
const PASS = process.env.RHOST_PASS;
if (!PASS) { console.error('RHOST_PASS env var is required'); process.exit(1); }

const result = await runner.run({
    host: 'localhost',
    port: 4201,
    username: 'Wizard',
    password: PASS,
});

process.exit(result.failed > 0 ? 1 : 0);

Quick start

Against an existing server

import { RhostClient } from '@rhost/testkit';

const PASS = process.env.RHOST_PASS;
if (!PASS) throw new Error('RHOST_PASS env var is required');

const client = new RhostClient({ host: 'localhost', port: 4201 });
await client.connect();
await client.login('Wizard', PASS);

const result = await client.eval('add(2,3)');
console.log(result); // '5'

await client.disconnect();

Spinning up Docker for CI

import { RhostRunner, RhostContainer } from '@rhost/testkit';

const PASS = process.env.RHOST_PASS;
if (!PASS) throw new Error('RHOST_PASS env var is required');

// Build from the rhostmush-docker source (first run is slow — compiles from source)
const container = RhostContainer.fromSource();
const info = await container.start(); // { host, port }

const runner = new RhostRunner();
runner.describe('sanity', ({ it }) => {
    it('add works', async ({ expect }) => {
        await expect('add(1,1)').toBe('2');
    });
});

const result = await runner.run({ ...info, username: 'Wizard', password: PASS });
await container.stop();
process.exit(result.failed > 0 ? 1 : 0);

Using the world fixture manager

import { RhostRunner } from '@rhost/testkit';

const runner = new RhostRunner();

runner.describe('attributes', ({ it, beforeEach, afterEach }) => {
    // world is auto-created fresh for each test and auto-cleaned after
    it('set and get attribute', async ({ world, client }) => {
        const obj = await world.create('TestObj');
        await world.set(obj, 'HP', '100');
        const val = await client.eval(`get(${obj}/HP)`);
        if (val.trim() !== '100') throw new Error(`Expected 100, got ${val}`);
    });
    // world.cleanup() is called automatically — no afterEach needed
});

Note: A fresh RhostWorld instance is provided to each it() test via the TestContext. It is automatically cleaned up (all created objects destroyed) after the test finishes, even on failure.


API reference — RhostRunner

Constructor

const runner = new RhostRunner();
// No arguments. Use runner.run(options) to set connection details.

Collection methods

runner.describe(name: string, fn: (ctx: SuiteContext) => void): this
runner.describe.skip(name: string, fn: (ctx: SuiteContext) => void): this
runner.describe.only(name: string, fn: (ctx: SuiteContext) => void): this

Calling describe.only() causes only suites marked only (at that level) to run; all others are skipped.

runner.run(options)

await runner.run(options: RunnerOptions): Promise<RunResult>

Connects to the MUSH server, runs all collected tests, disconnects, and returns the result.

RunnerOptions (extends RhostClientOptions):

| Option | Type | Default | Description | |--------|------|---------|-------------| | username | string | — | Required. Character name to log in as. | | password | string | — | Required. Character password. | | host | string | 'localhost' | MUSH server hostname. | | port | number | 4201 | MUSH telnet port. | | verbose | boolean | true | Print per-test results to stdout while running. | | timeout | number | 10000 | Per-eval/command timeout in ms. | | bannerTimeout | number | 300 | Ms to wait for welcome banner to finish. | | connectTimeout | number | 10000 | Raw TCP connection timeout in ms. | | paceMs | number | 0 | Delay between sent commands in ms (flood control). | | stripAnsi | boolean | true | Strip ANSI color codes from eval results. |

RunResult:

interface RunResult {
    passed:   number;                                              // tests that threw no error
    failed:   number;                                              // tests that threw
    skipped:  number;                                             // tests marked skip
    total:    number;                                              // passed + failed + skipped
    duration: number;                                             // wall-clock ms
    failures: Array<{ suite: string; test: string; error: Error }>;
}

SuiteContext — what describe() receives

interface SuiteContext {
    it(name: string, fn: TestFn, timeout?: number): void
    it.skip(name: string, fn: TestFn, timeout?: number): void
    it.only(name: string, fn: TestFn, timeout?: number): void

    test(name: string, fn: TestFn, timeout?: number): void  // alias for it
    test.skip / test.only                                    // same as it.skip / it.only

    describe(name: string, fn: (ctx: SuiteContext) => void): void
    describe.skip(...)
    describe.only(...)

    beforeAll(fn: HookFn): void    // runs once before all tests in this suite
    afterAll(fn: HookFn): void     // runs once after all tests in this suite
    beforeEach(fn: HookFn): void   // runs before every test in this suite (inherited by nested suites)
    afterEach(fn: HookFn): void    // runs after every test in this suite
}

TestContext — what it() receives

interface TestContext {
    expect(expression: string): RhostExpect              // create an expect for a softcode expression
    preview(input: string, opts?: PreviewOptions): Promise<string>  // print raw server output
    client: RhostClient                                  // the live MUSH connection
    world:  RhostWorld                                   // fresh per-test fixture manager (auto-cleaned)
}

HookFn — what beforeAll / beforeEach / etc. receive

type HookFn = (ctx: { client: RhostClient; world: RhostWorld }) => Promise<void> | void

Hook execution order

beforeAll      (suite level, once)
  beforeEach   (inherited from parent suites, then this suite)
    test body
  afterEach    (this suite only)
afterAll       (suite level, once)

beforeEach hooks are inherited by nested describe blocks. afterEach hooks are not inherited — they only run for tests in the suite where they were registered.

If a beforeAll hook throws, all tests in that suite (and nested suites) are counted as failed. The error is reported for each test.

If a beforeEach hook throws, that individual test is counted as failed and afterEach is skipped.

it.only / describe.only semantics

only applies at the sibling level. If any sibling at a given level is marked only, all siblings NOT marked only are skipped. only does not affect other describe blocks.


API reference — RhostExpect

Inside a test, expect('expression') evaluates the softcode expression and returns a RhostExpect. The result is lazily evaluated and cached — calling multiple matchers on the same expect() call evaluates the expression only once.

// Inside it():
async ({ expect }) => {
    await expect('add(2,3)').toBe('5');
    await expect('strlen(hello)').toBeNumber();
    await expect('lattr(#1)').toContainWord('ALIAS');
}

Negation

await expect('add(1,1)').not.toBe('3');
await expect('add(1,1)').not.toBeError();

All matchers

| Matcher | Behavior | |---------|----------| | .toBe(expected: string) | Exact match after .trim(). | | .toContain(substring: string) | Result includes the substring. | | .toMatch(pattern: RegExp \| string) | Regex test, or substring inclusion if string. | | .toStartWith(prefix: string) | Result starts with prefix. | | .toEndWith(suffix: string) | Result ends with suffix. | | .toBeNumber() | Result parses as a finite number. Empty string fails. | | .toBeCloseTo(expected: number, precision?: number) | \|actual − expected\| < 10^(−precision). Default precision: 3. | | .toBeTruthy() | Non-empty, not "0", and not a MUSH error (#-1/#-2/#-3). | | .toBeFalsy() | Empty string, "0", or a MUSH error. | | .toBeError() | Result starts with #-1, #-2, or #-3. | | .toBeDbref() | Result matches /^#\d+$/ (a positive object reference). | | .toContainWord(word: string, sep?: string) | Word is present in the space-delimited list (or custom separator). | | .toHaveWordCount(n: number, sep?: string) | List has exactly n words. Empty string has 0. | | .toMatchSnapshot() | Compare output against a stored snapshot; writes on first run. |

Failure message format

When a matcher fails, it throws RhostExpectError with this message:

expect("add(2,3)")
  ● .toBe failed
    Expected: "6"
    Received: "5"

Using RhostExpect without the runner

import { RhostClient, RhostExpect } from '@rhost/testkit';

const client = new RhostClient({ host: 'localhost', port: 4201 });
await client.connect();
await client.login('Wizard', 'Nyctasia');

const ex = new RhostExpect(client, 'add(2,3)');
await ex.toBe('5');
await ex.not.toBe('6');  // expression is already cached from toBe() above

await client.disconnect();

Snapshot testing

Snapshot tests lock in the output of a softcode expression. The first run writes the value to a .snap file; subsequent runs compare against it and diff on mismatch.

runner.describe('iter output', ({ it }) => {
    it('produces the right sequence', async ({ expect }) => {
        await expect('iter(lnum(1,5),##)').toMatchSnapshot();
    });
});

On first run, a file like __snapshots__/my-tests.test.ts.snap is created:

{
  "iter output > produces the right sequence: 1": "1 2 3 4 5"
}

On subsequent runs the stored value is compared. If it differs, the test fails with a line-by-line diff:

- 1 2 3 4 5
+ 1 2 3 4 5 6

To update all snapshots (e.g. after an intentional change):

RHOST_UPDATE_SNAPSHOTS=1 npx ts-node my-tests.test.ts

Or pass updateSnapshots: true to runner.run().

The runner prints a snapshot summary after each run: Snapshots: 3 passed, 1 written, 0 updated.

Snapshot key format: "Suite > Sub-suite > Test name: N" — where N is the 1-based call count within the test. Calling .toMatchSnapshot() twice in one test produces two separate keys.


API reference — RhostWorld

RhostWorld manages MUSH object fixtures. Objects created through world are registered and destroyed automatically in world.cleanup(). In the runner, cleanup() is called after every it() test — even on failure.

Constructor

const world = new RhostWorld(client: RhostClient);

Methods

await world.create(name: string, cost?: number): Promise<string>

Creates a THING via create(name) or create(name,cost). Returns the dbref (#42). Registers for cleanup.

await world.dig(name: string): Promise<string>

Creates a ROOM via @dig name. Returns the room dbref. Registers for cleanup.

await world.set(dbref: string, attr: string, value: string): Promise<void>

Sets an attribute: &attr dbref=value.

await world.get(dbref: string, attr: string): Promise<string>

Gets an attribute value via get(dbref/attr).

await world.flag(dbref: string, flag: string, clear?: boolean): Promise<void>

Sets (@set dbref=FLAG) or clears (@set dbref=!FLAG) a flag. clear defaults to false.

await world.lock(dbref: string, lockstring: string): Promise<void>

Locks an object: @lock dbref=lockstring.

await world.trigger(dbref: string, attr: string, args?: string): Promise<string[]>

Triggers @trigger dbref/attr=args. Returns all output lines captured before the sentinel.

await world.pemit(target: string, msg: string): Promise<void>

Emits a message to a target: @pemit target=msg.

await world.remit(room: string, msg: string): Promise<void>

Emits a message to all objects in a room: @remit room=msg.

await world.force(actor: string, cmd: string): Promise<void>

Forces an object to run a command: @force actor=cmd.

await world.parent(child: string, parent: string): Promise<void>

Sets a parent object: @parent child=parent.

await world.zone(name: string): Promise<string>

Creates a zone room via @dig name and sets INHERIT_ZONE. Returns the room dbref. Registers for cleanup.

await world.addToChannel(dbref: string, chan: string): Promise<void>

Adds an object to a channel: @channel/add chan=dbref.

await world.grantQuota(dbref: string, n: number): Promise<void>

Sets the build quota for an object: @quota/set dbref=n.

await world.wait(ms: number): Promise<void>

Pauses the test for ms milliseconds. Plain JavaScript delay — not a MUSH @wait.

await world.mail(to: string, subj: string, body: string): Promise<void>

Sends in-game mail: @mail to=subj/body.

await world.destroy(dbref: string): Promise<void>

Destroys a single object with @nuke. Also removes it from the cleanup list.

await world.cleanup(): Promise<void>

Destroys all registered objects in reverse-creation order. Errors from individual destroys are silently swallowed (the object may already be gone).

world.size: number

Number of objects currently registered for cleanup.

Input safety

All string inputs to world methods are validated by guardInput() before interpolation into MUSH commands. The guard rejects any string containing \n (newline) or \r (carriage return), which are the characters that split a single TCP send into multiple MUSH command lines.

Accepted: any string without \n or \r — including spaces, punctuation, and special MUSH characters. Rejected: strings containing \n or \r — throws RangeError.

await world.create('Test Obj-42');              // ✓ OK
await world.set('#1', 'HP', '100');             // ✓ OK
await world.set('#1', 'ATTR', 'a;b');           // ✓ OK (semicolons are not split chars)
await world.create('name\n@pemit me=injected'); // ✗ throws RangeError
await world.set('#1', 'AT\rTR', 'val');         // ✗ throws RangeError

LLM note: The guard covers newline-based command splitting. MUSH-level injection (e.g., semicolons or [ brackets in softcode contexts) is out of scope — world methods are test infrastructure, not a user-input sanitizer. Do not pass arbitrary end-user input directly to world methods.


API reference — RhostClient

Low-level TCP client. All higher-level classes are built on top of this.

Constructor

const client = new RhostClient(options?: RhostClientOptions);

RhostClientOptions:

| Option | Type | Default | Description | |--------|------|---------|-------------| | host | string | 'localhost' | Server hostname | | port | number | 4201 | Telnet port | | timeout | number | 10000 | Per-eval/command timeout (ms) | | bannerTimeout | number | 300 | Ms to wait for welcome banner | | stripAnsi | boolean | true | Strip ANSI/VT100 codes from results | | paceMs | number | 0 | Delay between commands (flood control) | | connectTimeout | number | 10000 | TCP connection establishment timeout (ms) |

Methods

await client.connect(): Promise<void>

Establishes the TCP connection and drains the welcome banner.

await client.login(username: string, password: string): Promise<void>

Sends connect <username> <password> and waits for the login sentinel. Throws RangeError if the username contains \n, \r, spaces, or tabs (which would split the MUSH connect command), or if the password contains \n or \r.

await client.eval(expression: string, timeout?: number): Promise<string>

Evaluates a softcode expression using think and captures the output. Trims trailing newlines. Strips ANSI if stripAnsi: true (the default). Returns the raw output string — may be empty, a number, a dbref, an error code, or a space-delimited list.

await client.command(cmd: string, timeout?: number): Promise<string[]>

Sends a MUSH command and captures all output lines until the sentinel. Returns an array of lines (may be empty).

await client.preview(input: string, options?: PreviewOptions): Promise<string>

Evaluate an expression or run a command and print the raw server output to stdout exactly as a MUSH client receives it — ANSI colours, formatting codes, and all. Output is rendered in a labelled frame. Returns the raw string so you can still assert on it.

PreviewOptions:

| Option | Type | Default | Description | |--------|------|---------|-------------| | mode | 'eval' \| 'command' | 'eval' | 'eval' wraps in think; 'command' sends as a raw MUSH command | | label | string | the input string | Custom frame header label | | timeout | number | client default | Per-call timeout (ms) | | print | boolean | true | Set false to suppress stdout and only use the return value |

// Softcode expression — see the raw colour output
await preview('ansi(rh,CRITICAL HIT!)');

// Room description as a player sees it
await preview('look here', { mode: 'command' });

// Assert without printing
const raw = await preview('ansi(b,test)', { print: false });
expect(stripAnsi(raw)).toBe('test');
client.onLine(handler: (line: string) => void): void
client.offLine(handler: (line: string) => void): void

Subscribe/unsubscribe to every raw line received from the server. Useful for debugging or log capture.

await client.disconnect(): Promise<void>

Sends QUIT and closes the TCP connection.

stripAnsi utility

import { stripAnsi } from '@rhost/testkit';
stripAnsi('\x1b[32mgreen\x1b[0m'); // => 'green'

isRhostError utility

import { isRhostError } from '@rhost/testkit';
isRhostError('#-1 NO MATCH');    // => true
isRhostError('#-2');             // => true
isRhostError('#42');             // => false
isRhostError('5');               // => false

Returns true if the string starts with #-1, #-2, or #-3.


API reference — RhostContainer

Spins up a RhostMUSH Docker container for isolated test runs. Uses testcontainers under the hood.

Factory methods

RhostContainer.fromImage(image?: string): RhostContainer

Use a pre-built Docker image. Default image: 'rhostmush:latest'. Build it first: docker build -t rhostmush:latest . (from the rhostmush-docker repo root).

RhostContainer.fromSource(projectRoot?: string): RhostContainer

Build the image from the Dockerfile in the rhostmush-docker project root. First run: ~5–10 minutes (clones and compiles RhostMUSH from source). Subsequent runs use Docker layer cache. projectRoot defaults to '../' relative to the installed package location.

Instance methods

await container.start(startupTimeout?: number): Promise<ContainerConnectionInfo>

Starts the container and waits for port 4201 to accept connections. startupTimeout defaults to 120000 ms (2 minutes). Returns { host: string; port: number } — the dynamically assigned host/port.

await container.stop(): Promise<void>

Stops and removes the container. Safe to call even if start() was never called.

container.getConnectionInfo(): ContainerConnectionInfo

Returns the current { host, port }. Throws if the container is not running.

Full usage example

import { RhostRunner, RhostContainer } from '@rhost/testkit';

const container = RhostContainer.fromSource();
const info = await container.start();
// info = { host: 'localhost', port: 32XXX }  (random high port)

const runner = new RhostRunner();
runner.describe('my system', ({ it }) => {
    it('works', async ({ expect }) => {
        await expect('add(1,1)').toBe('2');
    });
});

const PASS = process.env.RHOST_PASS;
if (!PASS) throw new Error('RHOST_PASS env var is required');

const result = await runner.run({
    ...info,                    // spreads host + port
    username: 'Wizard',
    password: PASS,
});

await container.stop();
process.exit(result.failed > 0 ? 1 : 0);

Offline validator

Validate softcode expressions without a server connection. Catches structural errors (unbalanced parens/brackets), wrong argument counts, and unknown built-in functions.

Programmatic API

import { validate, validateFile } from '@rhost/testkit/validator';

const result = validate('add(2,3)');
// result.valid       => true
// result.diagnostics => []

const bad = validate('add(2,3');
// bad.valid                 => false
// bad.diagnostics[0].code   => 'E001'
// bad.diagnostics[0].message => "Unclosed '(' ..."
const result = validateFile('./mycode.mush');

CLI

npx rhost-testkit validate "add(2,3)"
npx rhost-testkit validate --file mycode.mush
npx rhost-testkit validate --json "abs(1,2)"   # machine-readable output

Exit code 0 = valid (warnings allowed), 1 = one or more errors.

Diagnostic codes

| Code | Severity | Meaning | |------|----------|---------| | E001 | error | Unclosed ( | | E002 | error | Unexpected ) | | E003 | error | Unclosed [ | | E004 | error | Unexpected ] | | E006 | error | Too few arguments for known built-in | | E007 | error | Too many arguments for known built-in | | W001 | warning | Empty expression | | W002 | warning | Empty argument (e.g. add(,3)) | | W003 | warning | Deprecated function | | W005 | warning | Unknown function name (may be a UDF) | | W006 | warning | Register clobber risk — setq() inside a loop body may overwrite registers in concurrent iterations |

Dialect compatibility report

Report which functions are portable across MUSH platforms (RhostMUSH, PennMUSH, TinyMUX):

import { compatibilityReport } from '@rhost/testkit';

const report = compatibilityReport('json(get,key)');
// report.portable  => false
// report.restricted => [{ name: 'json', platforms: ['rhost'] }]
npx rhost-testkit validate --compat mycode.mush

Register clobber analysis

%q0%q9 registers are scoped per queue entry. The validator warns when setq() appears inside a loop body (iter(), parse(), map(), etc.) where concurrent invocations can silently overwrite each other's registers:

$ rhost-testkit validate --file combat.mush

WARNING W006: register clobber risk
  setq() writes %q0 inside iter() at offset 14.
  Wrap in localize() to scope registers to each iteration.

Softcode formatter

Normalize whitespace in softcode files — strips extra spaces around (, ,, ) while preserving interior argument text.

CLI

# Format a file in-place
npx rhost-testkit fmt mycode.mush

# Check without writing (exit 1 if not formatted — useful in CI)
npx rhost-testkit fmt --check mycode.mush

# Indent nested function calls for human readability
npx rhost-testkit fmt --pretty mycode.mush

# Normalize function names to lowercase
npx rhost-testkit fmt --lowercase mycode.mush

# Format from stdin
echo "add( 2, 3 )" | npx rhost-testkit fmt

Programmatic API

import { format } from '@rhost/testkit';

const result = format('add( 2, 3 )');
// result.formatted => 'add(2,3)'
// result.changed   => true

// Pretty mode — indents nested calls for readability (not for upload)
const pretty = format('add(mul(2,3),4)', { pretty: true });
// pretty.formatted => 'add(\n  mul(2,3),\n  4\n)'

// Lowercase function names
const lower = format('ADD(2,3)', { lowercase: true });
// lower.formatted => 'add(2,3)'

Interior whitespace within argument text is preserved — pemit(%#,hello world) is not changed.


Benchmark mode

Profile softcode performance against a live server. Reports median, p95, and p99 latency per expression.

Programmatic API

import { RhostBenchmark, formatBenchResults } from '@rhost/testkit';

const bench = new RhostBenchmark(client);

bench
  .add('add(2,3)', { name: 'addition', iterations: 100, warmup: 10 })
  .add('iter(lnum(1,100),##)', { name: 'heavy iter', iterations: 50, warmup: 5 });

const results = await bench.run();
console.log(formatBenchResults(results));

Output:

Benchmark Results
────────────────────────────────────────────────────────────────────────
  addition
  iterations: 100  warmup: 10
  median: 4.231ms  mean: 4.512ms  p95: 7.820ms  p99: 12.003ms
  min: 3.901ms  max: 14.221ms

  heavy iter
  iterations: 50  warmup: 5
  median: 18.440ms  mean: 19.201ms  p95: 31.100ms  p99: 38.500ms
  min: 16.002ms  max: 41.300ms
────────────────────────────────────────────────────────────────────────

runBench — single expression

import { runBench } from '@rhost/testkit';

const result = await runBench(client, 'iter(lnum(1,1000),##)', {
  name: 'heavy iter',
  iterations: 100,
  warmup: 10,
});

console.log(`median: ${result.median.toFixed(2)}ms`);
console.log(`p95: ${result.p95.toFixed(2)}ms`);

BenchmarkResult shape

interface BenchmarkResult {
  name:       string;
  iterations: number;
  warmup:     number;
  samples:    number[];   // raw timings in ms, in run order
  mean:       number;
  median:     number;
  p95:        number;
  p99:        number;
  min:        number;
  max:        number;
}

Watch mode

Re-run test files automatically on save.

# Auto-discover *.test.ts / *.spec.ts under the current directory
npx rhost-testkit watch

# Watch specific files
npx rhost-testkit watch src/__tests__/math.test.ts

# Options
npx rhost-testkit watch --debounce 500   # longer debounce (default: 300ms)
npx rhost-testkit watch --no-clear       # don't clear terminal between runs

TypeScript files are run with ts-node --transpile-only. Plain JS files are run with Node directly. Watch mode exits cleanly on Ctrl+C.


CI/CD templates

Generate a ready-to-use workflow file for your CI platform with one command:

# GitHub Actions → .github/workflows/mush-tests.yml
npx rhost-testkit init --ci github

# GitLab CI → .gitlab-ci.yml
npx rhost-testkit init --ci gitlab

# Overwrite an existing file
npx rhost-testkit init --ci github --force

The generated file includes:

  • Node.js 20 setup
  • npm ci + npm test
  • A commented-out block for optional integration tests against a rhostmush/rhostmush Docker container — uncomment and set RHOST_PASS in your secrets to enable

MUSH output format

Understanding what client.eval() returns is essential for writing correct assertions.

Normal results

| Softcode | Returns | |----------|---------| | add(2,3) | '5' | | strlen(hello) | '5' | | lcstr(HELLO) | 'hello' | | list(a b c) | 'a b c' (space-delimited) | | lattr(#1) | 'ALIAS MONIKER ...' (space-delimited attribute names) | | encode64(hello) | 'aGVsbG8=' | | create(Foo) | '#42' (a dbref) |

MUSH error codes

| Value | Meaning | |-------|---------| | #-1 or #-1 NO MATCH | Generic error / object not found | | #-2 | Permission denied | | #-3 | Invalid arguments |

Use isRhostError(result) or .toBeError() to detect these.

Multi-line output

client.eval() captures everything between the start and end sentinels and joins with \n. Most functions return a single line. client.command() returns an array of lines.

ANSI codes

Color codes are stripped by default (stripAnsi: true). To preserve them, set stripAnsi: false in RhostClientOptions.

Trailing whitespace

client.eval() trims trailing newlines. The .toBe() matcher trims both ends before comparing. Other matchers compare the raw (trimmed-newline-only) value.


Using with LLM skills

This section describes the standard workflow for an LLM (such as a Claude skill) to write and verify MUSHcode using @rhost/testkit.

Security considerations for LLM-generated code

When an LLM uses this SDK to generate and deploy softcode:

  1. Never auto-deploy from user input. Generate softcode, present it for human review, then deploy.
  2. Always use environment variables for credentials. Never hardcode passwords — not even the default.
  3. world methods are for test fixtures only. Do not pass arbitrary end-user strings to world.create(), world.set(), etc. without review. The newline guard prevents command splitting, but not MUSH-level injection in values.
  4. execscript() runs shell code. Never pass user-controlled strings as script names or arguments to execscript().
  5. Use paceMs to avoid flooding the server when generating many rapid eval calls.
  6. Telnet and the HTTP API are cleartext protocols. Use them only on localhost or a private network.
// ✗ UNSAFE — passes unreviewed user input to the server
const userInput = getUserInput();
await world.create(userInput);

// ✓ SAFE — validate, present for review, then act
if (!/^[A-Za-z0-9 _-]+$/.test(userInput)) throw new Error('Invalid object name');
console.log(`Deploying: create object "${userInput}" — review before proceeding`);
await world.create(userInput); // only after human approval

The workflow

1. Deploy softcode to the MUSH server
   └─ Paste commands into the running container via scripts/eval.js
       node scripts/eval.js "@create MySystem"
       node scripts/eval.js "&CMD_DOSOMETHING #42=..."

2. Write a test file
   └─ Use RhostRunner + describe/it/expect

3. Run the tests
   └─ RHOST_PASS=<your-password> npx ts-node my-system.test.ts

4. Red → fix softcode → Green → refactor

Minimal test file template

import { RhostRunner } from '@rhost/testkit';

// Require explicit password — never fall back to a default
const PASS = process.env.RHOST_PASS;
if (!PASS) { console.error('RHOST_PASS env var is required'); process.exit(1); }

const runner = new RhostRunner();

runner.describe('MySystem', ({ it, beforeAll }) => {
    // Suppress background cron/queue output that can bleed into eval results.
    // @halt/all me clears any pending server-side queue for the logged-in character.
    beforeAll(async ({ client }) => {
        await client.command('@halt/all me');
    });

    // Test the happy path
    it('does the thing', async ({ expect }) => {
        // Replace #42 with the actual dbref of your system object
        await expect('u(#42/FN_MYTHING,arg1)').toBe('expected output');
    });

    // Test that bad input is handled correctly
    it('returns error on bad input', async ({ expect }) => {
        await expect('u(#42/FN_MYTHING,)').toBeError();
    });
});

runner
    .run({ host: 'localhost', port: 4201, username: 'Wizard', password: PASS, timeout: 10000 })
    .then((r) => {
        console.log(`${r.passed} passed, ${r.failed} failed, ${r.skipped} skipped`);
        process.exit(r.failed > 0 ? 1 : 0);
    })
    .catch((err) => {
        console.error('Fatal: could not connect to MUSH server:', err.message);
        process.exit(1);
    });

Testing with object fixtures (world)

runner.describe('stat system', ({ it }) => {
    it('sets and reads HP', async ({ world, client, expect }) => {
        // world is fresh per test and auto-cleaned after
        const char = await world.create('TestChar');
        await world.set(char, 'HP', '50');
        await world.set(char, 'HP_MAX', '100');

        // Load your system's UDFs onto the char or call via #dbref
        await expect(`u(#42/FN_GETHP,${char})`).toBe('50');
    });

    it('triggers a command', async ({ world, client }) => {
        const char = await world.create('TestChar');
        const lines = await world.trigger(char, 'CMD_ATTACK', 'goblin');
        if (!lines.some(l => l.includes('attacks'))) {
            throw new Error(`Expected attack output, got: ${JSON.stringify(lines)}`);
        }
    });
});

Common patterns

Test a user-defined function (UDF):

await expect(`u(#42/FN_GREET,Alice)`).toBe('Hello, Alice!');

Test a command's output:

const lines = await client.command('+vote Alice');
// lines is string[] of all output until the sentinel

Test that a command modifies an attribute:

const obj = await world.create('Target');
await client.command(`+setstat ${obj}=STR/18`);
await expect(`get(${obj}/STAT.STR)`).toBe('18');

Test MUSH error handling:

await expect('u(#42/FN_DIVIDE,10,0)').toBeError();
await expect('u(#42/FN_DIVIDE,10,0)').toMatch(/#-1/);

Test a list result:

await expect('iter(1 2 3,mul(##,2))').toBe('2 4 6');
await expect('lattr(#1)').toContainWord('ALIAS');
await expect('lattr(#1)').toHaveWordCount(5);

Test numeric output:

await expect('add(0.1,0.2)').toBeCloseTo(0.3, 2);
await expect('sqrt(2)').toBeNumber();

Suppress MUSH background output:

beforeAll(async ({ client }) => {
    await client.command('@halt/all me');
    await client.command('@pemit me=ready');
});

Connecting to the Docker development server

# Start the server (from the repo root)
docker compose up --build -d

# Run tests (from sdk/) — set your own password
RHOST_PASS=<your-password> npx ts-node my-system.test.ts

Running tests in a self-contained container (no pre-existing server)

import { RhostRunner, RhostContainer } from '@rhost/testkit';

const container = RhostContainer.fromImage('rhostmush:latest'); // or fromSource()
const info = await container.start(); // waits until port 4201 is ready

// Deploy softcode using the scripts/eval.js tool first, or inline:
// const { execSync } = require('child_process');
// execSync(`node scripts/eval.js "@create MySystem" --host ${info.host} --port ${info.port}`);

const runner = new RhostRunner();
// ... add describe blocks ...

const result = await runner.run({ ...info, username: 'Wizard', password: process.env.RHOST_PASS! });
await container.stop();

Environment variables

Security: RHOST_PASS defaults to Nyctasia, which is public knowledge. Always set it explicitly — in any environment, including local dev. The examples in this README require it to be set; they will fail loudly if it is absent.

| Variable | Dev default | Description | |----------|-------------|-------------| | RHOST_HOST | localhost | Server hostname | | RHOST_PORT | 4201 | Telnet port | | RHOST_USER | Wizard | Login character name | | RHOST_PASS | Nyctasia (change this) | Login password — always override explicitly | | RHOST_API_PORT | 4202 | HTTP API port (examples 09–10) |

# Correct — explicit password
RHOST_PASS=my-secret-pass npx ts-node my-system.test.ts

# Wrong — relies on the public default
npx ts-node my-system.test.ts

Examples

The examples/ directory contains runnable test files. Start a server first (docker compose up --build -d from the repo root), then:

cd sdk
npx ts-node examples/01-functions.ts
# or via npm:
npm run example:01

| File | What it covers | |------|----------------| | 01-functions.ts | Math, strings, lists, control flow, type checks | | 02-rhost-specific.ts | encode64, digest, strdistance, soundex, localize | | 03-attributes.ts | Create objects, set/get attributes, flags, softcode | | 04-triggers.ts | @trigger: output capture, argument passing, chaining | | 05-runner-features.ts | it.skip, it.only, hooks, timeouts, RunResult | | 06-game-system.ts | End-to-end: stat system, modifiers, dice, character sheets | | 07-direct-client.ts | Low-level RhostClient without the runner | | 08-execscript.ts | Call shell/Python scripts from softcode via execscript() | | 09-api.ts | HTTP API: eval softcode over HTTP with Basic Auth | | 10-lua.ts | Embedded Lua via HTTP API |


Roadmap

See ROADMAP.md for full details and implementation notes.

✅ Shipped

| Version | Features | |---------|----------| | v0.2.0 | Offline validator · Watch mode · Snapshot testing | | v1.0.0 | Extended world API · CI/CD templates | | v1.1.0 | Server pre-flight assertions · Multi-persona test matrix · Side-effect assertion mode | | v1.2.0 | Register clobber analyzer · Deploy pipeline with rollback · Dialect compatibility report | | v1.3.0 | Softcode formatter (rhost-testkit fmt) · Benchmark mode (RhostBenchmark) |

Planned

Test coverage tracking — report which attributes on which objects were exercised during a run.

Interactive REPL — persistent connection with readline history and tab-complete for built-in functions.

Parallel test execution — run independent describe blocks concurrently on separate connections.

Recursion depth profiler — track max call depth per expression, warn before hitting server recursion limits.


License

MIT