{"id":"hurreman/luau-spec","name":"luau-spec","scope":"hurreman","platform":"roblox","description":"Spec runner for hurreman's Roblox games: one spec format that runs in Studio and offline under Lune (Rojo-aware requires, Roblox datatypes, fakes), plus a compile check","version":"0.1.0","latest":"0.1.0","versions":["0.1.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"69f673938ac0deca4f6c46ca8c4c74cd6a3e25c147f94025efd0beda724327f7","likes":0,"downloads":0,"install":"forest install hurreman/luau-spec","url":"https://forest.dev/p/roblox/hurreman/luau-spec","files":"https://api.forest.dev/ai/package/roblox/hurreman/luau-spec/files","readme":"# luau-spec\n\nOne spec runner for hurreman's Roblox games. The same spec files run:\n\n- **inside Roblox**, in Studio or a test place (`Spec.Run`);\n- **offline under Lune**, with no Studio (`lune/`).\n\nThe offline side loads your real modules with Roblox-style requires resolved through your Rojo project file. It provides real Roblox datatypes, and fakes for the rest.\n\n```toml\n# wally.toml\n[dev-dependencies]\nSpec = \"hurreman/luau-spec@0.1.0\"\n```\n\n## Writing specs\n\nA spec is a ModuleScript or file named `<Name>.spec.luau` (or `.spec.lua`). It may return a function in either of two styles.\n\n**Suite style.** Each `t.it` is a test:\n\n```lua\nreturn function(t)\n\tlocal Format = t.env().shared(\"Format\") -- offline only; see below\n\tt.describe(\"Format.number\", function()\n\t\tt.it(\"abbreviates thousands\", function()\n\t\t\tt.expect(Format.number(1234)).toBe(\"1.23K\")\n\t\tend)\n\tend)\nend\n```\n\n**Single-test style.** The whole function is one test, which fails if it raises. This is Slime Nebula's existing format:\n\n```lua\nreturn function()\n\tlocal Model = require(game:GetService(\"ReplicatedStorage\").ClientModules.UI.ActionHintModel)\n\tassert(Model.Build(nil) == nil)\nend\n```\n\n### What `t` provides\n\n**Both runners:**\n- `t.describe(name, fn)` and `t.it(name, fn)`\n- `t.expect(value)` with these matchers:\n  - `toBe`, `toEqual` (deep), `toBeCloseTo(n, eps?)`\n  - `toBeTruthy`, `toBeFalsy`, `toBeNil`, `toBeType(name)`\n  - `toBeGreaterThan`, `toBeLessThan`\n  - `toContain` (a substring, or a deep-equal item of a list)\n  - `toThrow(text?)` (plain text first, then as a Lua pattern)\n\n  Both `expect(x).toBe(y)` and `expect(x):toBe(y)` work.\n- `t.fakePlayer(userId, name)` and `t.fakeInstance(name)`: tables with attribute storage. `t.fakeSignal()`: a synchronous signal with `Fire` and `ConnectionCount`.\n\n**Lune runner only:**\n- `t.env(extra?)`: a fresh, isolated module set; see [Env](#env). `extra` globals, services and aliases merge over the runner's (e.g. `t.env({ services = { PhysicsService = recorder } })`).\n- `t.utc(y, m, d, hour?)`: Unix time of UTC midnight on that date.\n- `t.Random`: a seeded stand-in for Roblox's `Random`.\n\n**Your own fields:** anything you pass as `extras` also appears on `t`.\n\n## In Studio\n\n```lua\n-- ServerScriptService/Tests/RunAllTests.server.lua\nlocal RunService = game:GetService(\"RunService\")\nif not RunService:IsStudio() then\n\treturn\nend\nlocal Spec = require(game:GetService(\"ReplicatedStorage\").DevPackages.Spec)\ntask.delay(0.1, function()\n\tSpec.Run({ script.Parent }, {\n\t\tpattern = \"%.spec$\",\n\t\texcludeTags = { \"slow\" }, -- CollectionService tags, or a comma-separated TestTags / Tags attribute\n\t\tshuffle = false, seed = nil, verbose = false,\n\t\t-- reporter = { onStart, onTestPass, onTestFail, onFinish }\n\t})\nend)\n```\n\nMap `DevPackages` somewhere in your project, for example `ReplicatedStorage.DevPackages`. `Spec.Run` returns `{ passed, failed, results }`.\n\n## Offline under Lune\n\nEach game keeps a small wrapper script that finds the installed package and passes the game's settings:\n\n```lua\n-- tools/spec.luau     run from the repo root:  lune run tools/spec [NameFilter ...] [--shuffle] [--seed=N] [--verbose]\nlocal fs = require(\"@lune/fs\")\n\nlocal function package(): string\n\tfor _, root in { \"DevPackages\", \"Packages\" } do\n\t\tlocal index = root .. \"/_Index\"\n\t\tif fs.isDir(index) then\n\t\t\tfor _, name in fs.readDir(index) do\n\t\t\t\tif string.match(name, \"^hurreman_luau%-spec@\") then\n\t\t\t\t\treturn \"../\" .. index .. \"/\" .. name .. \"/luau-spec/lune\"\n\t\t\t\tend\n\t\t\tend\n\t\tend\n\tend\n\treturn \"../../luau-spec/lune\" -- a checkout next to this repo\nend\n\nlocal Spec = (require :: any)(package())\n\nSpec.run({\n\tproject = \"default.project.json\",\n\troots = { \"tools/unit/specs\" },\n\taliases = { shared = \"ReplicatedStorage.Shared\" }, -- t.env().shared(\"Config/Economy\")\n\t-- only = { \"CurrencyReducer\", ... },             -- run just these spec names\n})\n```\n\n`Spec.run` options:\n\n| Option | Meaning |\n|---|---|\n| `roots` | Folders searched recursively for spec files. |\n| `pattern` | Spec file name pattern. Default `%.spec%.luau?$`. |\n| `only` | Run just these spec names. |\n| `filters` | Run specs whose name contains one of these; default: the words passed on the command line. |\n| `project`, `aliases`, `globals`, `services`, `stubs` | Passed to every Env (below). |\n| `extras` | Extra fields added to `t`. |\n| `shuffle`, `seed`, `verbose`, `quiet` | Run order and output. |\n| `exit` | By default the process ends when the run does (code 1 on a failure, else 0), so loops a spec started can't keep Lune alive. `false` returns `(passed, failed)` instead, to run more afterwards. |\n\n### Compile check\n\n```lua\nSpec.compile({ roots = { \"src\" } })\n```\n\nThis compiles every `.luau` and `.lua` file under the roots, skipping Packages, DevPackages, ServerPackages and node_modules. It reports syntax errors without needing Studio.\n\n### Env\n\n`Spec.Env.new(options)` is the module set each spec file loads in. `t.env()` returns another fresh one.\n\n**How requires resolve:**\n- `require(script.Parent.X)` and `require(game:GetService(\"ReplicatedStorage\").Shared.X)` resolve through the Rojo project file.\n- Several `$path`s may share one instance path, as in Slime Nebula's per-place overlays.\n- A folder holding a `default.project.json` is a nested project, which is how Wally packages under `Packages/_Index` resolve. As in Rojo, its instance takes the project's `name`, not the folder's (`wally install` renames projects to match their folders).\n- Anything below a file (`X.luau/default.project.json`) is known missing without asking the file system.\n- Files outside the project, such as `tools/unit/specs`, resolve by folder.\n- `require(\"@lune/...\")` passes through to Lune, for offline-only specs.\n- Modules load from an in-memory copy of the files (`lune/Files.luau`), read ahead when the run starts and when an Env maps folders outside the working directory. Every `@lune/fs` call yields, and Roblox code requires modules from inside metamethods (a package's lazy `__index` loader), where a yield is an error.\n- `stubs = { [\"ReplicatedStorage.Shared.Net\"] = fake }` answers a require of that instance path with `fake` instead of loading the file (a module that only exists to talk to Roblox).\n\n**Globals:**\n- Roblox datatypes from `@lune/roblox` (`Vector3`, `CFrame`, `Color3`, `UDim2`, `Enum`, `Font` and others);\n- `task`, a seeded `Random`, `tick` and `time`;\n- a fake `workspace` with attributes;\n- `game`, whose `GetService` behaves as follows:\n  - `Workspace` returns the fake workspace;\n  - `HttpService` supports JSON encode/decode and GUIDs;\n  - inert stand-ins for CollectionService, RunService, Players, TweenService and SoundService;\n  - your `services` option is used as given;\n  - any other name returns a path. Requiring through it works; calling it fails loudly.\n\n**Methods on an env:**\n- `env.load(\"src/Theme/Resolve\")` loads by file path, without the extension.\n- `env.file(path)` loads an exact file.\n- `env.require(\"ReplicatedStorage.Shared.Format\")` loads by instance path.\n- `env.<alias>(name)` loads by alias.\n- `env.workspace` and `env.game` are the fakes above.\n\n## Moving the games over\n\n- **Slime Nebula**\n  1. Replace `ServerScriptService/Tests/TestRunner.lua` with `return require(<DevPackages>.Spec).Run`-style forwarding.\n  2. Replace `TestKit.lua` with `Expect = Spec.expect`, `DeepEqual = Spec.deepEqual`, `MakeFakePlayer = Spec.fakePlayer`.\n  3. Have `scripts/lune/run-pure-specs.luau` call `Spec.run({ project = \"lobby.project.json\", roots = { \"src/ServerScriptService/Tests\" }, only = DEFAULT_SPECS })`.\n\n  Verified: 51/51 pure specs pass unchanged.\n- **Magic Neighborhood:** `tools/unit/run.luau` becomes the wrapper above, with `aliases = { shared = \"ReplicatedStorage.Shared\" }`. Verified: 378/378 pass unchanged.\n- **Steal an Egg:** `tools/check/compile.luau` becomes `Spec.compile({ roots = { \"src\" } })`. Verified: same 93 files.\n- **roblox-ui-kit:** its specs `require(\"../Loader\")`. Switch them to `t.env().load(\"src/...\")` to drop `tests/Loader.luau` and `tests/run.luau`.\n\n## Developing\n\n```bash\nlune run tests/run          # this package's own specs (tests/specs, fixtures in tests/fixtures)\n```\n\nUse the Lune 0.10.5 exe under `~/.aftman/tool-storage/lune-org/lune/0.10.5/`. In a Lune `init.luau`, `./` means the folder's parent; use `@self/` for siblings.\n","readmeTruncated":false}