@rezless/vitest 0.1.0 → 0.2.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.
Files changed (2) hide show
  1. package/README.md +333 -0
  2. package/package.json +3 -3
package/README.md ADDED
@@ -0,0 +1,333 @@
1
+ # rezless
2
+
3
+ A **Vitest-style test framework for LSL** (Linden Scripting Language).
4
+
5
+ Tests run in milliseconds, in-process, with a virtual clock and mockable
6
+ `ll*` calls — no Second Life or OpenSim dependency.
7
+
8
+ ```ts
9
+ import { describe, it, expect, beforeEach } from 'vitest'
10
+ import { loadScript, vm } from '@rezless/vitest'
11
+ import type { Script } from '@rezless/vitest'
12
+
13
+ describe('greeter.lsl', () => {
14
+ let s: Script
15
+ beforeEach(async () => {
16
+ s = await loadScript('./greeter.lsl')
17
+ })
18
+
19
+ it('greets a toucher by name', () => {
20
+ s.start()
21
+ s.fire('touch_start', {
22
+ num_detected: 1,
23
+ detected: [{ key: 'aaa-...', name: 'Alice' }],
24
+ })
25
+ expect(s).toHaveSaid(0, 'Hello, Alice!')
26
+ })
27
+
28
+ it('reminds after 60s of silence', () => {
29
+ s.start()
30
+ s.fire('touch_start', { num_detected: 1, detected: [{ key: 'k', name: 'Alice' }] })
31
+ expect(s).toBeInState('waiting')
32
+ s.advanceTime(60_000)
33
+ expect(s).toHaveSaid(0, 'Anyone there?')
34
+ })
35
+ })
36
+ ```
37
+
38
+ ## Why
39
+
40
+ Up to now, the only way to test an LSL script has been to rez it
41
+ in-world and exercise it by hand. rezless gives you a real type-checked
42
+ TypeScript / Vitest experience with deterministic time, mockable `ll*`
43
+ calls, and rich assertions over chat output, state transitions, HTTP
44
+ requests, and script globals.
45
+
46
+ ## Packages
47
+
48
+ | Package | Purpose |
49
+ |---|---|
50
+ | `@rezless/parser` | Hand-written recursive-descent LSL parser |
51
+ | `@rezless/vm` | Tree-walking interpreter, virtual clock, dispatch |
52
+ | `@rezless/vitest` | `loadScript()` + custom matchers |
53
+
54
+ ## Install
55
+
56
+ ### Use it from another project
57
+
58
+ ```sh
59
+ pnpm add -D @rezless/vitest vitest
60
+ # or: npm i -D @rezless/vitest vitest
61
+ ```
62
+
63
+ `vitest` is a peer dependency. Then in your test file:
64
+
65
+ ```ts
66
+ import { loadScript } from '@rezless/vitest'
67
+ ```
68
+
69
+ ### Develop on rezless itself
70
+
71
+ ```sh
72
+ pnpm install
73
+ pnpm gen # regenerate ll* stub tables from vendor/kwdb.xml
74
+ pnpm typecheck
75
+ pnpm build # per-package tsc emit
76
+ pnpm test
77
+ ```
78
+
79
+ ## API
80
+
81
+ ### `loadScript(input)` → `Promise<Script>`
82
+
83
+ ```ts
84
+ await loadScript('./script.lsl')
85
+ await loadScript({
86
+ source: `default { state_entry() { llSay(0, "hi"); } }`,
87
+ filename: 'inline.lsl', // optional, used in diagnostics
88
+ randomSeed: 42, // optional, default 1
89
+ owner: 'aaaa-bbbb-...', // optional, NULL_KEY default
90
+ objectKey: 'cccc-dddd-...', // optional, derived from filename
91
+ objectName: 'StarterCube', // optional, "Object" default
92
+ scriptName: 'greeter', // optional, derived from filename
93
+ })
94
+ ```
95
+
96
+ Parse errors throw `LslParseError` with `file:line:col` location info.
97
+
98
+ ### `Script` handle
99
+
100
+ ```ts
101
+ // Drive events
102
+ s.start() // run state_entry of default
103
+ s.fire(eventName, payload) // payload binds to handler params by kwdb-spec name
104
+ s.advanceTime(ms) // advance virtual clock; drains timer + queued events
105
+ s.deliverChat({ channel, name, key, message }) // fire `listen` on matching active listens
106
+ s.respondToHttp(key, { status, body }) // fire `http_response` for a captured llHTTPRequest
107
+ s.respondToLastHttp({ status, body }) // same, for the most recent
108
+ s.respondToDataserver(key, value) // fire `dataserver` for a captured llRequest*
109
+ s.respondToLastDataserver(value) // same, for the most recent
110
+ s.mock(name, impl) // override any ll* function (real or stubbed) per-test
111
+ s.reset() // re-init globals + return to default + state_entry
112
+
113
+ // Inspect
114
+ s.currentState // current LSL state name
115
+ s.now // virtual time in ms
116
+ s.timerInterval // configured llSetTimerEvent interval (sec, 0 = unset)
117
+ s.running // false while paused via llSetScriptState
118
+ s.global(name) // read a script global
119
+ s.setGlobal(name, value) // seed a script global
120
+ s.chat // [{ channel, text, type, to? }, ...]
121
+ s.calls // [{ name, args, returned }, ...]
122
+ s.callsOf(name) // filter call log by ll* name
123
+ s.httpRequests // [{ key, url, method, body, headers, mimetype, ... }]
124
+ s.dataserverRequests // [{ key, source, args, fulfilled }]
125
+ s.listens // [{ handle, channel, name, key, message, active }]
126
+ s.linkedMessages // [{ target, num, str, id }] sent by THIS script
127
+ s.clearLinkedMessages() // drop captured llMessageLinked entries
128
+ s.linksetData // ReadonlyMap<string, { value, password }> (shared LSD store)
129
+ s.seedLinksetData(entries) // pre-populate LSD without firing events
130
+ s.text // current llSetText: { text, color, alpha } or null
131
+ s.objectDesc // current llSetObjectDesc value
132
+ s.dead // true once llDie has run
133
+
134
+ // Multi-script context (auto-allocated for single-script tests)
135
+ s.host // the Prim instance hosting this script
136
+ s.linkset // the Linkset (shared clock, LSD, link_message bus)
137
+ s.scriptName // inventory name; backs llGetScriptName
138
+ ```
139
+
140
+ ### `loadLinkset(input)` → `Promise<LoadedLinkset>`
141
+
142
+ For tests that exercise more than one script — sibling scripts in the same
143
+ prim, or scripts spread across a multi-prim linkset — use `loadLinkset`.
144
+ Every script shares one virtual clock, one Linkset Data store, and one
145
+ `link_message` bus, so `llMessageLinked`, `linkset_data`, `llGetTime`,
146
+ `llResetOtherScript`, and `llSetScriptState` all behave as they do in-world.
147
+
148
+ ```ts
149
+ import { loadLinkset } from '@rezless/vitest'
150
+
151
+ const { linkset, prims, scripts } = await loadLinkset({
152
+ prims: [
153
+ {
154
+ name: 'Root',
155
+ scripts: [
156
+ { source: './counter.lsl', name: 'counter' },
157
+ { source: { source: peekerSource, filename: 'peeker.lsl' }, name: 'peeker' },
158
+ ],
159
+ inventory: [
160
+ // notecards, textures, … addressable via llGetInventory* / llGetNotecardLine
161
+ ],
162
+ },
163
+ { name: 'Display', scripts: [{ source: './display.lsl', name: 'display' }] },
164
+ ],
165
+ })
166
+
167
+ scripts['counter'].start()
168
+ scripts['display'].start()
169
+ scripts['counter'].fire('touch_start', { num_detected: 1 })
170
+ expect(scripts['display'].text?.text).toBe('count: 1')
171
+
172
+ // Linkset-wide helpers
173
+ linkset.advanceTime(1_000) // shared clock; every script's timers advance together
174
+ linkset.deliverChat({ channel: 0, name: 'Alice', key: 'k', message: 'hi' })
175
+ linkset.linkedMessages // every llMessageLinked across the linkset
176
+ linkset.clearLinkedMessages()
177
+ linkset.clock.now // current virtual time in ms
178
+ linkset.clock.pendingEvents() // snapshot of queued one-shot events:
179
+ // [{ at, target, event, payload }, ...]
180
+ ```
181
+
182
+ `scripts` is a flat name → `Script` lookup keyed by inventory name; duplicate
183
+ names across prims throw to prevent silent overwrites. Each `Script` is the
184
+ same handle described above, so all matchers, mocks, and inspectors keep
185
+ working — they're just scoped to one script in a larger system.
186
+
187
+ `loadScript` continues to work for single-script tests; under the hood it
188
+ auto-allocates a 1-prim/1-script linkset, so existing tests are unaffected.
189
+
190
+ ### Custom matchers
191
+
192
+ ```ts
193
+ expect(s).toHaveSaid(0, 'hello')
194
+ expect(s).toBeInState('waiting')
195
+ expect(s).toHaveCalledFunction('llSetTimerEvent', 60.0)
196
+ expect(s).toHaveSentHTTP({ url: '...', method: 'POST', body: '...' })
197
+ expect(s).toHaveListened(7, { key: ownerKey })
198
+ ```
199
+
200
+ ### Implemented `ll*` functions
201
+
202
+ The real-impl set covers the most common use cases. Anything outside it
203
+ falls through to a typed stub that returns the kwdb-documented default
204
+ and records the call. Use `s.mock(name, fn)` to provide your own
205
+ behaviour for any function the script under test calls. Builtin
206
+ function delays (e.g. the 0.2s delay on `llSetLinkPrimitiveParams`) are
207
+ honored centrally in the dispatcher.
208
+
209
+ * **chat**: `llSay`, `llShout`, `llWhisper`, `llOwnerSay`
210
+ * **listen**: `llListen`, `llListenRemove`, `llListenControl`
211
+ * **time**: `llSetTimerEvent`, `llSleep`, `llGetTime`, `llGetAndResetTime`, `llResetTime`
212
+ * **HTTP**: `llHTTPRequest`, `llHTTPResponse`
213
+ * **linked**: `llMessageLinked` (full `LINK_THIS` / `LINK_SET` / `LINK_ALL_OTHERS` / `LINK_ALL_CHILDREN` / `LINK_ROOT` / specific-link routing across the linkset, with correct `sender_num`)
214
+ * **link info**: `llGetLinkNumber`, `llGetNumberOfPrims`, `llGetLinkKey`, `llGetLinkName`
215
+ * **dataserver**: `llRequestAgentData`, `llRequestInventoryData`, `llRequestSimulatorData`, `llRequestUsername`, `llRequestDisplayName`
216
+ * **detected**: `llDetectedKey`/`Name`/`Owner`/`Group`/`Pos`/`Rot`/`Vel`/`Type`/`LinkNumber`/`Grab`/`TouchPos`
217
+ * **math**: `llAbs`, `llFabs`, `llRound` (banker's), `llCeil`, `llFloor`, `llPow`, `llSqrt`, `llSin`/`Cos`/`Tan`/`Asin`/`Acos`/`Atan2`, `llLog`/`Log10`, seeded `llFrand`, `llVecMag`/`Norm`/`Dist`, `llRot2Euler`/`Euler2Rot`
218
+ * **string**: `llStringLength`, `llSubStringIndex`, `llGetSubString`, `llDeleteSubString`, `llInsertString`, `llStringTrim`, `llToLower`/`Upper`, `llReplaceSubString`, `llEscapeURL`/`UnescapeURL`
219
+ * **list**: `llGetListLength`, `llList2Integer`/`Float`/`String`/`Key`/`Vector`/`Rot`, `llList2List`, `llDeleteSubList`, `llListInsertList`, `llListReplaceList`, `llListFindList`, `llDumpList2String`, `llCSV2List`, `llParseString2List`
220
+ * **json**: `llJson2List`, `llJsonGetValue`, `llJsonSetValue`, `llJsonValueType`, `llList2Json` — full LSL JSON semantics including the FDDx-range type-tag sentinels (`JSON_OBJECT`/`ARRAY`/`NUMBER`/`STRING`/`NULL`/`TRUE`/`FALSE`/`INVALID`/`DELETE`), `JSON_APPEND`, the empty-value short-circuit (`{"k":,}` → `JSON_NULL`) on `Get`/`Type`, duplicate-key collapse on `Set`, force-fit auto-creation of nested objects/arrays through deep specifier paths, and the kept-as-is rule for nested JSON-shaped strings in `llList2Json`.
221
+ * **identity**: `llGetOwner`, `llGetOwnerKey`, `llGetCreator`, `llGetKey`, `llGetObjectName`, `llSetObjectName`, `llGetScriptName`
222
+ * **hash**: `llMD5String`, `llSHA1String`, `llSHA256String`, `llHMAC`
223
+ * **base64**: `llStringToBase64`/`Base64ToString`, `llIntegerToBase64`/`Base64ToInteger`
224
+ * **object**: `llSetText`, `llSetObjectDesc`, `llGetObjectDesc`, `llDie` (kills every script in the linkset), `llResetScript`
225
+ * **inventory**: `llGetInventoryNumber`/`Name`/`Type`/`Key`/`Creator`/`Desc`/`AcquireTime`/`PermMask` against the host prim's inventory; notecards via `llGetNotecardLine` / `llGetNumberOfNotecardLines` (correct EOF / NAK semantics, ISO 8601 UTC acquire-time strings)
226
+ * **script control**: `llResetOtherScript`, `llSetScriptState`, `llGetScriptState` — pausing parks events for replay on resume, and the timer cadence realigns on resume so paused scripts don't get a flood of catch-up timer events
227
+ * **linkset data**: `llLinksetDataWrite`/`Read`/`Delete`/`WriteProtected`/`ReadProtected`/`DeleteProtected`/`DeleteFound`, `llLinksetDataReset`, `llLinksetDataAvailable`, `llLinksetDataCountKeys`/`CountFound`, `llLinksetDataListKeys`/`FindKeys` — fires the `linkset_data` event on every script in the linkset with `LINKSETDATA_UPDATE` / `DELETE` / `RESET` / `MULTIDELETE`. The store survives `llResetScript`. Inspect via `s.linksetData` (a `ReadonlyMap<string, { value, password }>`); pre-populate via `s.seedLinksetData([['k', { value: 'v' }]])`.
228
+ * **primitive params**: `llSetPrimitiveParams`, `llSetLinkPrimitiveParams`, `llSetLinkPrimitiveParamsFast`, `llGetPrimitiveParams`, `llGetLinkPrimitiveParams` — full `PRIM_*` rule surface (position / rotation / size / colour / alpha / texture / glow / fullbright / material / physics / flexible / point light / projector / sculpt / GLTF PBR / sit target / click action / text / name / desc / omega / damage / temp-on-rez / cast shadows / …). Honors `PRIM_LINK_TARGET` mid-rule redirects, `ALL_SIDES` expansion, multi-prim get concatenation, unknown-rule termination on get, and the 0.2 s delay on the slow link variant.
229
+ * **prim accessors** (alternative single-rule shortcuts that read/write the same backing store): `llSetPos`/`llGetPos`/`llGetLocalPos`/`llGetRootPosition`, `llSetRot`/`llSetLocalRot`/`llGetRot`/`llGetLocalRot`/`llGetRootRotation`, `llSetScale`/`llGetScale`/`llScaleByFactor`/`llGetMaxScaleFactor`/`llGetMinScaleFactor`, `llSetColor`/`llGetColor`/`llSetAlpha`/`llGetAlpha`, `llSetTexture`/`llGetTexture`/`llGetTextureOffset`/`llGetTextureScale`/`llGetTextureRot`/`llScaleTexture`/`llOffsetTexture`/`llRotateTexture`, `llSetLinkColor`/`llSetLinkAlpha`/`llSetLinkTexture`, `llSetTextureAnim`/`llSetLinkTextureAnim`/`llSetLinkTextureAnimOverrideMe`, `llSetRenderMaterial`/`llGetRenderMaterial`, `llSetStatus`/`llGetStatus`/`llSetLinkStatus`, `llSetClickAction`, `llTargetOmega`, `llSetPhysicsMaterial`/`llGetPhysicsMaterial`, `llPassCollisions`/`llPassTouches`, `llSitTarget`/`llLinkSitTarget`/`llAvatarOnSitTarget`/`llAvatarOnLinkSitTarget`/`llSetLinkSitFlags`/`llGetLinkSitFlags`.
230
+
231
+ The full LSL constant surface (every `PRIM_*`, `STATUS_*`, `LINK_*`,
232
+ `INVENTORY_*`, `MASK_*`, `LINKSETDATA_*`, …) is re-exported from
233
+ `@rezless/vitest`, so tests can compare numeric returns against named
234
+ constants instead of magic numbers.
235
+
236
+ ## Coverage
237
+
238
+ `@rezless/vitest` ships an LSL coverage collector that tracks four kinds of
239
+ coverage per script: **statement**, **branch** (if / while / for / do-while
240
+ arms), **function** (user functions and event handlers), and **state**
241
+ ("did we ever enter `state idle`?"). Off by default; opt in once in your
242
+ `vitest.config.ts`:
243
+
244
+ ```ts
245
+ import { defineConfig } from 'vitest/config'
246
+ import { LslCoverageReporter } from '@rezless/vitest/reporter'
247
+
248
+ export default defineConfig({
249
+ test: {
250
+ reporters: ['default', new LslCoverageReporter()],
251
+ coverage: {
252
+ // your @vitest/coverage-v8 config (provider, include, exclude, ...)
253
+ },
254
+ },
255
+ })
256
+ ```
257
+
258
+ The reporter is dormant during plain `vitest run`. When you invoke
259
+ `vitest run --coverage`, both pipelines fire:
260
+
261
+ - JS coverage from `@vitest/coverage-v8` lands at `coverage/`.
262
+ - LSL coverage lands at `coverage/lsl/`:
263
+ - `lcov.info` — opens in VS Code Coverage Gutters / Codecov / Coveralls
264
+ - `coverage-final.json` — Istanbul-shaped, mergeable with JS coverage
265
+ - `html/index.html` — browseable per-file source view with line annotations
266
+ - per-file pass/total table on stdout
267
+
268
+ Two alternative activation paths:
269
+
270
+ ```ts
271
+ // Per-test, programmatic — useful for in-test assertions.
272
+ const s = await loadScript({ source, filename, coverage: true })
273
+ s.start()
274
+ expect(s.coverage!.functions.find((f) => f.name === 'bump')!.hits).toBeGreaterThan(0)
275
+ ```
276
+
277
+ ```sh
278
+ # Without the reporter installed — env var collects, CLI renders.
279
+ LSL_COVERAGE=1 pnpm test
280
+ pnpm exec rezless-coverage
281
+ ```
282
+
283
+ By default, reports for synthetic filenames (`<inline>`, names without a
284
+ path separator, paths that don't exist on disk) are omitted — they're
285
+ almost always throwaway test fixtures. Pass `--include-fixtures` to the
286
+ CLI, or `{ includeFixtures: true }` to the reporter, to keep them.
287
+
288
+ `examples/coverage/` has a runnable demo.
289
+
290
+ ## Examples
291
+
292
+ `examples/` ships working scripts with tests:
293
+
294
+ * **hello** — minimal `state_entry { llSay(...) }` + matchers.
295
+ * **greeter** — touch + name greeting + state transition + reminder timer.
296
+ * **remote** — owner-only listen on a custom channel + command dispatch.
297
+ * **fetcher** — `llHTTPRequest` + `http_response` + status handling.
298
+ * **nametag** — `llRequestAgentData` + `dataserver` + floating text.
299
+ * **mocking** — patterns for `script.mock(...)`: pinning a real built-in
300
+ to a deterministic value, providing custom logic for an unimplemented
301
+ function, observing call args via the call log, and stateful mocks
302
+ that aggregate across multiple events.
303
+ * **scoreboard** — Linkset Data: per-name counters, regex key lookup
304
+ via `llLinksetDataFindKeys`, `linkset_data` event handling, and
305
+ state that survives `llResetScript`.
306
+ * **multi-script** — two scripts in different prims of the same linkset:
307
+ a counter in the root prim drives a display in a child prim via
308
+ `llMessageLinked`, and a third script writing the same LSD key
309
+ refreshes the display via the broadcast `linkset_data` event.
310
+ Wired up with `loadLinkset({ prims: [...] })`.
311
+ * **coverage** — opt-in coverage tracking: a small voter script
312
+ exercising statement / branch / function / state coverage with
313
+ in-test assertions on `script.coverage`.
314
+ * **json** — JSON config blob with nested paths: `set theme.color red`
315
+ / `get theme.color` / `keys` commands, persisted to Linkset Data so
316
+ the document survives `llResetScript`. Exercises all five JSON
317
+ builtins, nested-object force-fit on `llJsonSetValue`, and the
318
+ bare-word/sentinel round-trip on `llJsonGetValue`.
319
+
320
+ ## License
321
+
322
+ The rezless project is licensed under the **Apache License, Version 2.0** —
323
+ see [`LICENSE`](LICENSE).
324
+
325
+ LSL function/event/constant signatures come from
326
+ [Sei-Lisa/kwdb](https://github.com/Sei-Lisa/kwdb), vendored at
327
+ `vendor/kwdb.xml` and licensed under **LGPL-3.0-or-later**. Files
328
+ mechanically derived from it (`packages/vm/src/generated/*.ts`) remain
329
+ under that licence; see [`NOTICE`](NOTICE) for the full attribution.
330
+
331
+ Apache 2.0 is one-way compatible with LGPL-3 (per the FSF), so a
332
+ combined work that includes both can be redistributed under LGPL-3
333
+ terms — which is what consumers receive when they use rezless.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rezless/vitest",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Vitest-style DSL for testing Linden Scripting Language (LSL) scripts.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "i-doll",
@@ -52,8 +52,8 @@
52
52
  "access": "public"
53
53
  },
54
54
  "dependencies": {
55
- "@rezless/parser": "0.1.0",
56
- "@rezless/vm": "0.1.0"
55
+ "@rezless/parser": "0.2.0",
56
+ "@rezless/vm": "0.2.0"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "vitest": "^4.1.5"