@rhost/testkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,861 @@
1
+ # @rhost/testkit
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@rhost/testkit.svg)](https://www.npmjs.com/package/@rhost/testkit)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@rhost/testkit.svg)](https://www.npmjs.com/package/@rhost/testkit)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![CI](https://github.com/RhostMUSH/rhostmush-docker/actions/workflows/security-tests.yml/badge.svg)](https://github.com/RhostMUSH/rhostmush-docker/actions/workflows/security-tests.yml)
7
+ [![Security](https://img.shields.io/badge/Security-Audited-brightgreen.svg)](./SECURITY.md)
8
+
9
+ A Jest-like testing framework for [RhostMUSH](https://github.com/RhostMUSH/trunk) softcode.
10
+ Write tests that run directly against a real MUSH server — local, CI container, or remote.
11
+
12
+ ```bash
13
+ npm install @rhost/testkit
14
+ ```
15
+
16
+ ---
17
+
18
+ ## Contents
19
+
20
+ - [What it does](#what-it-does)
21
+ - [Installation](#installation)
22
+ - [How the runner works](#how-the-runner-works)
23
+ - [Quick start](#quick-start)
24
+ - [API reference — RhostRunner](#api-reference--rhostrunner)
25
+ - [API reference — RhostExpect](#api-reference--rhostexpect)
26
+ - [API reference — RhostWorld](#api-reference--rhostworld)
27
+ - [API reference — RhostClient](#api-reference--rhostclient)
28
+ - [API reference — RhostContainer](#api-reference--rhostcontainer)
29
+ - [MUSH output format](#mush-output-format)
30
+ - [Using with LLM skills](#using-with-llm-skills)
31
+ - [Environment variables](#environment-variables)
32
+ - [Examples](#examples)
33
+
34
+ ---
35
+
36
+ ## What it does
37
+
38
+ `@rhost/testkit` gives you a full test-runner loop for MUSHcode:
39
+
40
+ - **Eval** softcode expressions and capture their output
41
+ - **Assert** results with a Jest-like `expect()` API and MUSH-aware matchers
42
+ - **Manage fixtures** — create objects, set attributes, and auto-destroy everything after each test
43
+ - **Spin up RhostMUSH in Docker** for isolated, reproducible CI runs
44
+ - **Report** results with a pretty, indented pass/fail tree
45
+
46
+ ---
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ npm install @rhost/testkit
52
+ ```
53
+
54
+ **Peer requirements:**
55
+ - Node.js ≥ 18
56
+ - Docker (only for `RhostContainer` — not required when connecting to an existing server)
57
+ - TypeScript ≥ 5.0 (if using TypeScript)
58
+
59
+ ---
60
+
61
+ ## How the runner works
62
+
63
+ `RhostRunner` uses a **two-phase** collect → run model identical to Jest:
64
+
65
+ ```
66
+ Phase 1 — collect: runner.describe() / runner.describe.skip() calls build a tree in memory.
67
+ Phase 2 — run: runner.run(options) connects to the MUSH server and executes the tree.
68
+ ```
69
+
70
+ The `describe()` callback runs **synchronously** during collection.
71
+ The `it()` callback runs **asynchronously** during execution.
72
+ All `await` calls belong inside `it()` callbacks, not inside `describe()` callbacks.
73
+
74
+ ```typescript
75
+ import { RhostRunner } from '@rhost/testkit';
76
+
77
+ const runner = new RhostRunner();
78
+
79
+ // Phase 1 — synchronous collection
80
+ runner.describe('add()', ({ it }) => {
81
+ it('adds two numbers', async ({ expect }) => {
82
+ await expect('add(2,3)').toBe('5');
83
+ });
84
+ it('handles negatives', async ({ expect }) => {
85
+ await expect('add(-1,1)').toBe('0');
86
+ });
87
+ });
88
+
89
+ // Phase 2 — async execution (require explicit password — no fallback)
90
+ const PASS = process.env.RHOST_PASS;
91
+ if (!PASS) { console.error('RHOST_PASS env var is required'); process.exit(1); }
92
+
93
+ const result = await runner.run({
94
+ host: 'localhost',
95
+ port: 4201,
96
+ username: 'Wizard',
97
+ password: PASS,
98
+ });
99
+
100
+ process.exit(result.failed > 0 ? 1 : 0);
101
+ ```
102
+
103
+ ---
104
+
105
+ ## Quick start
106
+
107
+ ### Against an existing server
108
+
109
+ ```typescript
110
+ import { RhostClient } from '@rhost/testkit';
111
+
112
+ const PASS = process.env.RHOST_PASS;
113
+ if (!PASS) throw new Error('RHOST_PASS env var is required');
114
+
115
+ const client = new RhostClient({ host: 'localhost', port: 4201 });
116
+ await client.connect();
117
+ await client.login('Wizard', PASS);
118
+
119
+ const result = await client.eval('add(2,3)');
120
+ console.log(result); // '5'
121
+
122
+ await client.disconnect();
123
+ ```
124
+
125
+ ### Spinning up Docker for CI
126
+
127
+ ```typescript
128
+ import { RhostRunner, RhostContainer } from '@rhost/testkit';
129
+
130
+ const PASS = process.env.RHOST_PASS;
131
+ if (!PASS) throw new Error('RHOST_PASS env var is required');
132
+
133
+ // Build from the rhostmush-docker source (first run is slow — compiles from source)
134
+ const container = RhostContainer.fromSource();
135
+ const info = await container.start(); // { host, port }
136
+
137
+ const runner = new RhostRunner();
138
+ runner.describe('sanity', ({ it }) => {
139
+ it('add works', async ({ expect }) => {
140
+ await expect('add(1,1)').toBe('2');
141
+ });
142
+ });
143
+
144
+ const result = await runner.run({ ...info, username: 'Wizard', password: PASS });
145
+ await container.stop();
146
+ process.exit(result.failed > 0 ? 1 : 0);
147
+ ```
148
+
149
+ ### Using the world fixture manager
150
+
151
+ ```typescript
152
+ import { RhostRunner } from '@rhost/testkit';
153
+
154
+ const runner = new RhostRunner();
155
+
156
+ runner.describe('attributes', ({ it, beforeEach, afterEach }) => {
157
+ // world is auto-created fresh for each test and auto-cleaned after
158
+ it('set and get attribute', async ({ world, client }) => {
159
+ const obj = await world.create('TestObj');
160
+ await world.set(obj, 'HP', '100');
161
+ const val = await client.eval(`get(${obj}/HP)`);
162
+ if (val.trim() !== '100') throw new Error(`Expected 100, got ${val}`);
163
+ });
164
+ // world.cleanup() is called automatically — no afterEach needed
165
+ });
166
+ ```
167
+
168
+ > **Note:** A fresh `RhostWorld` instance is provided to each `it()` test via the `TestContext`.
169
+ > It is automatically cleaned up (all created objects destroyed) after the test finishes, even on failure.
170
+
171
+ ---
172
+
173
+ ## API reference — RhostRunner
174
+
175
+ ### Constructor
176
+
177
+ ```typescript
178
+ const runner = new RhostRunner();
179
+ // No arguments. Use runner.run(options) to set connection details.
180
+ ```
181
+
182
+ ### Collection methods
183
+
184
+ ```typescript
185
+ runner.describe(name: string, fn: (ctx: SuiteContext) => void): this
186
+ runner.describe.skip(name: string, fn: (ctx: SuiteContext) => void): this
187
+ runner.describe.only(name: string, fn: (ctx: SuiteContext) => void): this
188
+ ```
189
+
190
+ Calling `describe.only()` causes only suites marked `only` (at that level) to run; all others are skipped.
191
+
192
+ ### `runner.run(options)`
193
+
194
+ ```typescript
195
+ await runner.run(options: RunnerOptions): Promise<RunResult>
196
+ ```
197
+
198
+ Connects to the MUSH server, runs all collected tests, disconnects, and returns the result.
199
+
200
+ **`RunnerOptions`** (extends `RhostClientOptions`):
201
+
202
+ | Option | Type | Default | Description |
203
+ |--------|------|---------|-------------|
204
+ | `username` | `string` | — | **Required.** Character name to log in as. |
205
+ | `password` | `string` | — | **Required.** Character password. |
206
+ | `host` | `string` | `'localhost'` | MUSH server hostname. |
207
+ | `port` | `number` | `4201` | MUSH telnet port. |
208
+ | `verbose` | `boolean` | `true` | Print per-test results to stdout while running. |
209
+ | `timeout` | `number` | `10000` | Per-eval/command timeout in ms. |
210
+ | `bannerTimeout` | `number` | `300` | Ms to wait for welcome banner to finish. |
211
+ | `connectTimeout` | `number` | `10000` | Raw TCP connection timeout in ms. |
212
+ | `paceMs` | `number` | `0` | Delay between sent commands in ms (flood control). |
213
+ | `stripAnsi` | `boolean` | `true` | Strip ANSI color codes from eval results. |
214
+
215
+ **`RunResult`:**
216
+
217
+ ```typescript
218
+ interface RunResult {
219
+ passed: number; // tests that threw no error
220
+ failed: number; // tests that threw
221
+ skipped: number; // tests marked skip
222
+ total: number; // passed + failed + skipped
223
+ duration: number; // wall-clock ms
224
+ failures: Array<{ suite: string; test: string; error: Error }>;
225
+ }
226
+ ```
227
+
228
+ ### `SuiteContext` — what `describe()` receives
229
+
230
+ ```typescript
231
+ interface SuiteContext {
232
+ it(name: string, fn: TestFn, timeout?: number): void
233
+ it.skip(name: string, fn: TestFn, timeout?: number): void
234
+ it.only(name: string, fn: TestFn, timeout?: number): void
235
+
236
+ test(name: string, fn: TestFn, timeout?: number): void // alias for it
237
+ test.skip / test.only // same as it.skip / it.only
238
+
239
+ describe(name: string, fn: (ctx: SuiteContext) => void): void
240
+ describe.skip(...)
241
+ describe.only(...)
242
+
243
+ beforeAll(fn: HookFn): void // runs once before all tests in this suite
244
+ afterAll(fn: HookFn): void // runs once after all tests in this suite
245
+ beforeEach(fn: HookFn): void // runs before every test in this suite (inherited by nested suites)
246
+ afterEach(fn: HookFn): void // runs after every test in this suite
247
+ }
248
+ ```
249
+
250
+ ### `TestContext` — what `it()` receives
251
+
252
+ ```typescript
253
+ interface TestContext {
254
+ expect(expression: string): RhostExpect // create an expect for a softcode expression
255
+ client: RhostClient // the live MUSH connection
256
+ world: RhostWorld // fresh per-test fixture manager (auto-cleaned)
257
+ }
258
+ ```
259
+
260
+ ### `HookFn` — what `beforeAll` / `beforeEach` / etc. receive
261
+
262
+ ```typescript
263
+ type HookFn = (ctx: { client: RhostClient; world: RhostWorld }) => Promise<void> | void
264
+ ```
265
+
266
+ ### Hook execution order
267
+
268
+ ```
269
+ beforeAll (suite level, once)
270
+ beforeEach (inherited from parent suites, then this suite)
271
+ test body
272
+ afterEach (this suite only)
273
+ afterAll (suite level, once)
274
+ ```
275
+
276
+ `beforeEach` hooks are **inherited** by nested `describe` blocks.
277
+ `afterEach` hooks are **not** inherited — they only run for tests in the suite where they were registered.
278
+
279
+ If a `beforeAll` hook throws, all tests in that suite (and nested suites) are counted as failed. The error is reported for each test.
280
+
281
+ If a `beforeEach` hook throws, that individual test is counted as failed and `afterEach` is skipped.
282
+
283
+ ### `it.only` / `describe.only` semantics
284
+
285
+ `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.
286
+
287
+ ---
288
+
289
+ ## API reference — RhostExpect
290
+
291
+ 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.
292
+
293
+ ```typescript
294
+ // Inside it():
295
+ async ({ expect }) => {
296
+ await expect('add(2,3)').toBe('5');
297
+ await expect('strlen(hello)').toBeNumber();
298
+ await expect('lattr(#1)').toContainWord('ALIAS');
299
+ }
300
+ ```
301
+
302
+ ### Negation
303
+
304
+ ```typescript
305
+ await expect('add(1,1)').not.toBe('3');
306
+ await expect('add(1,1)').not.toBeError();
307
+ ```
308
+
309
+ ### All matchers
310
+
311
+ | Matcher | Behavior |
312
+ |---------|----------|
313
+ | `.toBe(expected: string)` | Exact match after `.trim()`. |
314
+ | `.toContain(substring: string)` | Result includes the substring. |
315
+ | `.toMatch(pattern: RegExp \| string)` | Regex test, or substring inclusion if string. |
316
+ | `.toStartWith(prefix: string)` | Result starts with prefix. |
317
+ | `.toEndWith(suffix: string)` | Result ends with suffix. |
318
+ | `.toBeNumber()` | Result parses as a finite number. Empty string fails. |
319
+ | `.toBeCloseTo(expected: number, precision?: number)` | `\|actual − expected\| < 10^(−precision)`. Default precision: 3. |
320
+ | `.toBeTruthy()` | Non-empty, not `"0"`, and not a MUSH error (`#-1`/`#-2`/`#-3`). |
321
+ | `.toBeFalsy()` | Empty string, `"0"`, or a MUSH error. |
322
+ | `.toBeError()` | Result starts with `#-1`, `#-2`, or `#-3`. |
323
+ | `.toBeDbref()` | Result matches `/^#\d+$/` (a positive object reference). |
324
+ | `.toContainWord(word: string, sep?: string)` | Word is present in the space-delimited list (or custom separator). |
325
+ | `.toHaveWordCount(n: number, sep?: string)` | List has exactly `n` words. Empty string has 0. |
326
+
327
+ ### Failure message format
328
+
329
+ When a matcher fails, it throws `RhostExpectError` with this message:
330
+
331
+ ```
332
+ expect("add(2,3)")
333
+ ● .toBe failed
334
+ Expected: "6"
335
+ Received: "5"
336
+ ```
337
+
338
+ ### Using `RhostExpect` without the runner
339
+
340
+ ```typescript
341
+ import { RhostClient, RhostExpect } from '@rhost/testkit';
342
+
343
+ const client = new RhostClient({ host: 'localhost', port: 4201 });
344
+ await client.connect();
345
+ await client.login('Wizard', 'Nyctasia');
346
+
347
+ const ex = new RhostExpect(client, 'add(2,3)');
348
+ await ex.toBe('5');
349
+ await ex.not.toBe('6'); // expression is already cached from toBe() above
350
+
351
+ await client.disconnect();
352
+ ```
353
+
354
+ ---
355
+
356
+ ## API reference — RhostWorld
357
+
358
+ `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.
359
+
360
+ ### Constructor
361
+
362
+ ```typescript
363
+ const world = new RhostWorld(client: RhostClient);
364
+ ```
365
+
366
+ ### Methods
367
+
368
+ ```typescript
369
+ await world.create(name: string, cost?: number): Promise<string>
370
+ ```
371
+ Creates a THING via `create(name)` or `create(name,cost)`. Returns the dbref (`#42`). Registers for cleanup.
372
+
373
+ ```typescript
374
+ await world.dig(name: string): Promise<string>
375
+ ```
376
+ Creates a ROOM via `@dig name`. Returns the room dbref. Registers for cleanup.
377
+
378
+ ```typescript
379
+ await world.set(dbref: string, attr: string, value: string): Promise<void>
380
+ ```
381
+ Sets an attribute: `&attr dbref=value`.
382
+
383
+ ```typescript
384
+ await world.get(dbref: string, attr: string): Promise<string>
385
+ ```
386
+ Gets an attribute value via `get(dbref/attr)`.
387
+
388
+ ```typescript
389
+ await world.flag(dbref: string, flag: string, clear?: boolean): Promise<void>
390
+ ```
391
+ Sets (`@set dbref=FLAG`) or clears (`@set dbref=!FLAG`) a flag. `clear` defaults to `false`.
392
+
393
+ ```typescript
394
+ await world.lock(dbref: string, lockstring: string): Promise<void>
395
+ ```
396
+ Locks an object: `@lock dbref=lockstring`.
397
+
398
+ ```typescript
399
+ await world.trigger(dbref: string, attr: string, args?: string): Promise<string[]>
400
+ ```
401
+ Triggers `@trigger dbref/attr=args`. Returns all output lines captured before the sentinel.
402
+
403
+ ```typescript
404
+ await world.destroy(dbref: string): Promise<void>
405
+ ```
406
+ Destroys a single object with `@nuke`. Also removes it from the cleanup list.
407
+
408
+ ```typescript
409
+ await world.cleanup(): Promise<void>
410
+ ```
411
+ Destroys all registered objects in reverse-creation order. Errors from individual destroys are silently swallowed (the object may already be gone).
412
+
413
+ ```typescript
414
+ world.size: number
415
+ ```
416
+ Number of objects currently registered for cleanup.
417
+
418
+ ### Input safety
419
+
420
+ 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.
421
+
422
+ **Accepted:** any string without `\n` or `\r` — including spaces, punctuation, and special MUSH characters.
423
+ **Rejected:** strings containing `\n` or `\r` — throws `RangeError`.
424
+
425
+ ```typescript
426
+ await world.create('Test Obj-42'); // ✓ OK
427
+ await world.set('#1', 'HP', '100'); // ✓ OK
428
+ await world.set('#1', 'ATTR', 'a;b'); // ✓ OK (semicolons are not split chars)
429
+ await world.create('name\n@pemit me=injected'); // ✗ throws RangeError
430
+ await world.set('#1', 'AT\rTR', 'val'); // ✗ throws RangeError
431
+ ```
432
+
433
+ > **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.
434
+
435
+ ---
436
+
437
+ ## API reference — RhostClient
438
+
439
+ Low-level TCP client. All higher-level classes are built on top of this.
440
+
441
+ ### Constructor
442
+
443
+ ```typescript
444
+ const client = new RhostClient(options?: RhostClientOptions);
445
+ ```
446
+
447
+ **`RhostClientOptions`:**
448
+
449
+ | Option | Type | Default | Description |
450
+ |--------|------|---------|-------------|
451
+ | `host` | `string` | `'localhost'` | Server hostname |
452
+ | `port` | `number` | `4201` | Telnet port |
453
+ | `timeout` | `number` | `10000` | Per-eval/command timeout (ms) |
454
+ | `bannerTimeout` | `number` | `300` | Ms to wait for welcome banner |
455
+ | `stripAnsi` | `boolean` | `true` | Strip ANSI/VT100 codes from results |
456
+ | `paceMs` | `number` | `0` | Delay between commands (flood control) |
457
+ | `connectTimeout` | `number` | `10000` | TCP connection establishment timeout (ms) |
458
+
459
+ ### Methods
460
+
461
+ ```typescript
462
+ await client.connect(): Promise<void>
463
+ ```
464
+ Establishes the TCP connection and drains the welcome banner.
465
+
466
+ ```typescript
467
+ await client.login(username: string, password: string): Promise<void>
468
+ ```
469
+ Sends `connect <username> <password>` and waits for the login sentinel. Throws `RangeError` if either credential contains `\n` or `\r`.
470
+
471
+ ```typescript
472
+ await client.eval(expression: string, timeout?: number): Promise<string>
473
+ ```
474
+ 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.
475
+
476
+ ```typescript
477
+ await client.command(cmd: string, timeout?: number): Promise<string[]>
478
+ ```
479
+ Sends a MUSH command and captures all output lines until the sentinel. Returns an array of lines (may be empty).
480
+
481
+ ```typescript
482
+ client.onLine(handler: (line: string) => void): void
483
+ client.offLine(handler: (line: string) => void): void
484
+ ```
485
+ Subscribe/unsubscribe to every raw line received from the server. Useful for debugging or log capture.
486
+
487
+ ```typescript
488
+ await client.disconnect(): Promise<void>
489
+ ```
490
+ Sends `QUIT` and closes the TCP connection.
491
+
492
+ ### `stripAnsi` utility
493
+
494
+ ```typescript
495
+ import { stripAnsi } from '@rhost/testkit';
496
+ stripAnsi('\x1b[32mgreen\x1b[0m'); // => 'green'
497
+ ```
498
+
499
+ ### `isRhostError` utility
500
+
501
+ ```typescript
502
+ import { isRhostError } from '@rhost/testkit';
503
+ isRhostError('#-1 NO MATCH'); // => true
504
+ isRhostError('#-2'); // => true
505
+ isRhostError('#42'); // => false
506
+ isRhostError('5'); // => false
507
+ ```
508
+
509
+ Returns `true` if the string starts with `#-1`, `#-2`, or `#-3`.
510
+
511
+ ---
512
+
513
+ ## API reference — RhostContainer
514
+
515
+ Spins up a RhostMUSH Docker container for isolated test runs. Uses [testcontainers](https://node.testcontainers.org/) under the hood.
516
+
517
+ ### Factory methods
518
+
519
+ ```typescript
520
+ RhostContainer.fromImage(image?: string): RhostContainer
521
+ ```
522
+ Use a pre-built Docker image. Default image: `'rhostmush:latest'`.
523
+ Build it first: `docker build -t rhostmush:latest .` (from the rhostmush-docker repo root).
524
+
525
+ ```typescript
526
+ RhostContainer.fromSource(projectRoot?: string): RhostContainer
527
+ ```
528
+ Build the image from the `Dockerfile` in the rhostmush-docker project root.
529
+ First run: ~5–10 minutes (clones and compiles RhostMUSH from source). Subsequent runs use Docker layer cache.
530
+ `projectRoot` defaults to `'../'` relative to the installed package location.
531
+
532
+ ### Instance methods
533
+
534
+ ```typescript
535
+ await container.start(startupTimeout?: number): Promise<ContainerConnectionInfo>
536
+ ```
537
+ Starts the container and waits for port 4201 to accept connections.
538
+ `startupTimeout` defaults to `120000` ms (2 minutes).
539
+ Returns `{ host: string; port: number }` — the dynamically assigned host/port.
540
+
541
+ ```typescript
542
+ await container.stop(): Promise<void>
543
+ ```
544
+ Stops and removes the container. Safe to call even if `start()` was never called.
545
+
546
+ ```typescript
547
+ container.getConnectionInfo(): ContainerConnectionInfo
548
+ ```
549
+ Returns the current `{ host, port }`. Throws if the container is not running.
550
+
551
+ ### Full usage example
552
+
553
+ ```typescript
554
+ import { RhostRunner, RhostContainer } from '@rhost/testkit';
555
+
556
+ const container = RhostContainer.fromSource();
557
+ const info = await container.start();
558
+ // info = { host: 'localhost', port: 32XXX } (random high port)
559
+
560
+ const runner = new RhostRunner();
561
+ runner.describe('my system', ({ it }) => {
562
+ it('works', async ({ expect }) => {
563
+ await expect('add(1,1)').toBe('2');
564
+ });
565
+ });
566
+
567
+ const PASS = process.env.RHOST_PASS;
568
+ if (!PASS) throw new Error('RHOST_PASS env var is required');
569
+
570
+ const result = await runner.run({
571
+ ...info, // spreads host + port
572
+ username: 'Wizard',
573
+ password: PASS,
574
+ });
575
+
576
+ await container.stop();
577
+ process.exit(result.failed > 0 ? 1 : 0);
578
+ ```
579
+
580
+ ---
581
+
582
+ ## MUSH output format
583
+
584
+ Understanding what `client.eval()` returns is essential for writing correct assertions.
585
+
586
+ ### Normal results
587
+
588
+ | Softcode | Returns |
589
+ |----------|---------|
590
+ | `add(2,3)` | `'5'` |
591
+ | `strlen(hello)` | `'5'` |
592
+ | `lcstr(HELLO)` | `'hello'` |
593
+ | `list(a b c)` | `'a b c'` (space-delimited) |
594
+ | `lattr(#1)` | `'ALIAS MONIKER ...'` (space-delimited attribute names) |
595
+ | `encode64(hello)` | `'aGVsbG8='` |
596
+ | `create(Foo)` | `'#42'` (a dbref) |
597
+
598
+ ### MUSH error codes
599
+
600
+ | Value | Meaning |
601
+ |-------|---------|
602
+ | `#-1` or `#-1 NO MATCH` | Generic error / object not found |
603
+ | `#-2` | Permission denied |
604
+ | `#-3` | Invalid arguments |
605
+
606
+ Use `isRhostError(result)` or `.toBeError()` to detect these.
607
+
608
+ ### Multi-line output
609
+
610
+ `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.
611
+
612
+ ### ANSI codes
613
+
614
+ Color codes are stripped by default (`stripAnsi: true`). To preserve them, set `stripAnsi: false` in `RhostClientOptions`.
615
+
616
+ ### Trailing whitespace
617
+
618
+ `client.eval()` trims trailing newlines. The `.toBe()` matcher trims both ends before comparing. Other matchers compare the raw (trimmed-newline-only) value.
619
+
620
+ ---
621
+
622
+ ## Using with LLM skills
623
+
624
+ This section describes the standard workflow for an LLM (such as a Claude skill) to write and verify MUSHcode using `@rhost/testkit`.
625
+
626
+ ### Security considerations for LLM-generated code
627
+
628
+ When an LLM uses this SDK to generate and deploy softcode:
629
+
630
+ 1. **Never auto-deploy from user input.** Generate softcode, present it for human review, then deploy.
631
+ 2. **Always use environment variables for credentials.** Never hardcode passwords — not even the default.
632
+ 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.
633
+ 4. **`execscript()` runs shell code.** Never pass user-controlled strings as script names or arguments to `execscript()`.
634
+ 5. **Use `paceMs`** to avoid flooding the server when generating many rapid eval calls.
635
+ 6. **Telnet and the HTTP API are cleartext protocols.** Use them only on localhost or a private network.
636
+
637
+ ```typescript
638
+ // ✗ UNSAFE — passes unreviewed user input to the server
639
+ const userInput = getUserInput();
640
+ await world.create(userInput);
641
+
642
+ // ✓ SAFE — validate, present for review, then act
643
+ if (!/^[A-Za-z0-9 _-]+$/.test(userInput)) throw new Error('Invalid object name');
644
+ console.log(`Deploying: create object "${userInput}" — review before proceeding`);
645
+ await world.create(userInput); // only after human approval
646
+ ```
647
+
648
+ ---
649
+
650
+ ### The workflow
651
+
652
+ ```
653
+ 1. Deploy softcode to the MUSH server
654
+ └─ Paste commands into the running container via scripts/eval.js
655
+ node scripts/eval.js "@create MySystem"
656
+ node scripts/eval.js "&CMD_DOSOMETHING #42=..."
657
+
658
+ 2. Write a test file
659
+ └─ Use RhostRunner + describe/it/expect
660
+
661
+ 3. Run the tests
662
+ └─ RHOST_PASS=<your-password> npx ts-node my-system.test.ts
663
+
664
+ 4. Red → fix softcode → Green → refactor
665
+ ```
666
+
667
+ ### Minimal test file template
668
+
669
+ ```typescript
670
+ import { RhostRunner } from '@rhost/testkit';
671
+
672
+ // Require explicit password — never fall back to a default
673
+ const PASS = process.env.RHOST_PASS;
674
+ if (!PASS) { console.error('RHOST_PASS env var is required'); process.exit(1); }
675
+
676
+ const runner = new RhostRunner();
677
+
678
+ runner.describe('MySystem', ({ it, beforeAll }) => {
679
+ // Suppress background cron/queue output that can bleed into eval results.
680
+ // @halt/all me clears any pending server-side queue for the logged-in character.
681
+ beforeAll(async ({ client }) => {
682
+ await client.command('@halt/all me');
683
+ });
684
+
685
+ // Test the happy path
686
+ it('does the thing', async ({ expect }) => {
687
+ // Replace #42 with the actual dbref of your system object
688
+ await expect('u(#42/FN_MYTHING,arg1)').toBe('expected output');
689
+ });
690
+
691
+ // Test that bad input is handled correctly
692
+ it('returns error on bad input', async ({ expect }) => {
693
+ await expect('u(#42/FN_MYTHING,)').toBeError();
694
+ });
695
+ });
696
+
697
+ runner
698
+ .run({ host: 'localhost', port: 4201, username: 'Wizard', password: PASS, timeout: 10000 })
699
+ .then((r) => {
700
+ console.log(`${r.passed} passed, ${r.failed} failed, ${r.skipped} skipped`);
701
+ process.exit(r.failed > 0 ? 1 : 0);
702
+ })
703
+ .catch((err) => {
704
+ console.error('Fatal: could not connect to MUSH server:', err.message);
705
+ process.exit(1);
706
+ });
707
+ ```
708
+
709
+ ### Testing with object fixtures (world)
710
+
711
+ ```typescript
712
+ runner.describe('stat system', ({ it }) => {
713
+ it('sets and reads HP', async ({ world, client, expect }) => {
714
+ // world is fresh per test and auto-cleaned after
715
+ const char = await world.create('TestChar');
716
+ await world.set(char, 'HP', '50');
717
+ await world.set(char, 'HP_MAX', '100');
718
+
719
+ // Load your system's UDFs onto the char or call via #dbref
720
+ await expect(`u(#42/FN_GETHP,${char})`).toBe('50');
721
+ });
722
+
723
+ it('triggers a command', async ({ world, client }) => {
724
+ const char = await world.create('TestChar');
725
+ const lines = await world.trigger(char, 'CMD_ATTACK', 'goblin');
726
+ if (!lines.some(l => l.includes('attacks'))) {
727
+ throw new Error(`Expected attack output, got: ${JSON.stringify(lines)}`);
728
+ }
729
+ });
730
+ });
731
+ ```
732
+
733
+ ### Common patterns
734
+
735
+ **Test a user-defined function (UDF):**
736
+ ```typescript
737
+ await expect(`u(#42/FN_GREET,Alice)`).toBe('Hello, Alice!');
738
+ ```
739
+
740
+ **Test a command's output:**
741
+ ```typescript
742
+ const lines = await client.command('+vote Alice');
743
+ // lines is string[] of all output until the sentinel
744
+ ```
745
+
746
+ **Test that a command modifies an attribute:**
747
+ ```typescript
748
+ const obj = await world.create('Target');
749
+ await client.command(`+setstat ${obj}=STR/18`);
750
+ await expect(`get(${obj}/STAT.STR)`).toBe('18');
751
+ ```
752
+
753
+ **Test MUSH error handling:**
754
+ ```typescript
755
+ await expect('u(#42/FN_DIVIDE,10,0)').toBeError();
756
+ await expect('u(#42/FN_DIVIDE,10,0)').toMatch(/#-1/);
757
+ ```
758
+
759
+ **Test a list result:**
760
+ ```typescript
761
+ await expect('iter(1 2 3,mul(##,2))').toBe('2 4 6');
762
+ await expect('lattr(#1)').toContainWord('ALIAS');
763
+ await expect('lattr(#1)').toHaveWordCount(5);
764
+ ```
765
+
766
+ **Test numeric output:**
767
+ ```typescript
768
+ await expect('add(0.1,0.2)').toBeCloseTo(0.3, 2);
769
+ await expect('sqrt(2)').toBeNumber();
770
+ ```
771
+
772
+ **Suppress MUSH background output:**
773
+ ```typescript
774
+ beforeAll(async ({ client }) => {
775
+ await client.command('@halt/all me');
776
+ await client.command('@pemit me=ready');
777
+ });
778
+ ```
779
+
780
+ ### Connecting to the Docker development server
781
+
782
+ ```bash
783
+ # Start the server (from the repo root)
784
+ docker compose up --build -d
785
+
786
+ # Run tests (from sdk/) — set your own password
787
+ RHOST_PASS=<your-password> npx ts-node my-system.test.ts
788
+ ```
789
+
790
+ ### Running tests in a self-contained container (no pre-existing server)
791
+
792
+ ```typescript
793
+ import { RhostRunner, RhostContainer } from '@rhost/testkit';
794
+
795
+ const container = RhostContainer.fromImage('rhostmush:latest'); // or fromSource()
796
+ const info = await container.start(); // waits until port 4201 is ready
797
+
798
+ // Deploy softcode using the scripts/eval.js tool first, or inline:
799
+ // const { execSync } = require('child_process');
800
+ // execSync(`node scripts/eval.js "@create MySystem" --host ${info.host} --port ${info.port}`);
801
+
802
+ const runner = new RhostRunner();
803
+ // ... add describe blocks ...
804
+
805
+ const result = await runner.run({ ...info, username: 'Wizard', password: process.env.RHOST_PASS! });
806
+ await container.stop();
807
+ ```
808
+
809
+ ---
810
+
811
+ ## Environment variables
812
+
813
+ > **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.
814
+
815
+ | Variable | Dev default | Description |
816
+ |----------|-------------|-------------|
817
+ | `RHOST_HOST` | `localhost` | Server hostname |
818
+ | `RHOST_PORT` | `4201` | Telnet port |
819
+ | `RHOST_USER` | `Wizard` | Login character name |
820
+ | `RHOST_PASS` | `Nyctasia` **(change this)** | Login password — always override explicitly |
821
+ | `RHOST_API_PORT` | `4202` | HTTP API port (examples 09–10) |
822
+
823
+ ```bash
824
+ # Correct — explicit password
825
+ RHOST_PASS=my-secret-pass npx ts-node my-system.test.ts
826
+
827
+ # Wrong — relies on the public default
828
+ npx ts-node my-system.test.ts
829
+ ```
830
+
831
+ ---
832
+
833
+ ## Examples
834
+
835
+ The [`examples/`](https://github.com/RhostMUSH/rhostmush-docker/tree/main/sdk/examples) directory contains runnable test files. Start a server first (`docker compose up --build -d` from the repo root), then:
836
+
837
+ ```bash
838
+ cd sdk
839
+ npx ts-node examples/01-functions.ts
840
+ # or via npm:
841
+ npm run example:01
842
+ ```
843
+
844
+ | File | What it covers |
845
+ |------|----------------|
846
+ | `01-functions.ts` | Math, strings, lists, control flow, type checks |
847
+ | `02-rhost-specific.ts` | encode64, digest, strdistance, soundex, localize |
848
+ | `03-attributes.ts` | Create objects, set/get attributes, flags, softcode |
849
+ | `04-triggers.ts` | @trigger: output capture, argument passing, chaining |
850
+ | `05-runner-features.ts` | it.skip, it.only, hooks, timeouts, RunResult |
851
+ | `06-game-system.ts` | End-to-end: stat system, modifiers, dice, character sheets |
852
+ | `07-direct-client.ts` | Low-level `RhostClient` without the runner |
853
+ | `08-execscript.ts` | Call shell/Python scripts from softcode via execscript() |
854
+ | `09-api.ts` | HTTP API: eval softcode over HTTP with Basic Auth |
855
+ | `10-lua.ts` | Embedded Lua via HTTP API |
856
+
857
+ ---
858
+
859
+ ## License
860
+
861
+ MIT