Run your roblox-ts and Luau tests inside Roblox, get results in your terminal.
- roblox-ts and pure Luau
- Source-mapped errors (Luau line numbers back to
.tsfiles) - Code coverage (in-process Luau AST instrumentation)
- Three backends: Open Cloud (remote), Studio (attached, local), and Studio CLI (self-launched headless Studio, local)
- Multiple output formatters (human, agent, JSON, GitHub Actions)
- Live stage progress, so a long upload or dispatch never looks stalled
Note
roblox-ts projects currently require @isentinel/roblox-ts for source maps and coverage support.
npm install @isentinel/jest-robloxType tests need TypeScript 7, an optional peer dependency. Install it only if
you run --typecheck or --typecheckOnly; runtime tests do not use it.
npm install -D typescript@7A project that keeps TypeScript 6 for other tools can install 7 beside it as
@typescript/native, which type tests use first:
npm install -D @typescript/native@npm:typescript@7An existing @typescript/native-preview install still works.
Pre-built binaries are attached to each GitHub release. Install with your preferred tool manager:
mise use github:christopher-buss/jest-roblox-cli
rokit add christopher-buss/jest-roblox-cli
Limitations vs the npm package:
- TypeScript Type Tests (
.spec-d.ts) are not available; Luau Type Tests (.spec-d.luau) run whenluteis on yourPATH .tsconfig files are not supported (usejest.config.luau,.json,.js, or.mjs)- External tools (rojo) must still be on your
PATH
Add a jest.config.ts (or .js, .json, .yaml, .toml) to your project
root. A Luau-only project can write jest.config.luau instead (see
Luau configs).
import { defineConfig } from "@isentinel/jest-roblox";
export default defineConfig({
placeFile: "./game.rbxl",
test: {
projects: ["ReplicatedStorage/shared"],
},
});Then run:
jest-robloxNote
projects is optional. With it omitted, the CLI derives one project per
luauRoots mount (the compiled-output dirs your Rojo project mounts,
auto-detected from your tsconfig outDir), generating each project's
jest.config stub for you. Set projects explicitly when you need
per-project overrides (a distinct displayName, setupFiles, include,
etc.) or a tighter project layout than the luau roots imply.
A package with no .project.json, no rojoProject setting and no
tsconfig.json gets a Rojo project built from its file layout, written to
.jest-roblox-layout.project.json on each run (add it to .gitignore). Each
top-level directory except node_modules and dot-directories mounts under
ReplicatedStorage/<package directory>, so a relative string require such as
require("../src") resolves the same in the place as on disk. Jest comes from
the nearest node_modules/@rbxts-js.project.json above the package, mounted at
ReplicatedStorage/node_modules/@rbxts-js. The project sets
Workspace.SignalBehavior to Deferred.
# Run all tests
jest-roblox
# Run one file (TypeScript or Luau)
jest-roblox src/player.spec.ts
jest-roblox src/player.spec.luau
# Filter by test name
jest-roblox -t "should spawn"
# Filter by file path
jest-roblox --testPathPattern player
jest-roblox --testPathPattern="modifiers|define\\.spec|triggers"
# Use a specific backend (default "auto" picks studio-cli if Roblox Studio is
# installed, else Open Cloud — see Backends below)
jest-roblox --backend studio
jest-roblox --backend studio-cli
jest-roblox --backend open-cloud
# Collect coverage
jest-roblox --coverage
# Save game output (print/warn/error) to file
jest-roblox --gameOutput game-logs.txt
# Run only specific named projects (repeat the flag per project)
jest-roblox --project client
jest-roblox --project client --project serverConfig files are loaded by c12, which
auto-discovers jest.config.* in any format it supports (.ts, .js, .mjs,
.cjs, .json, .yaml, .toml). A jest.config.luau is discovered too (see
Luau configs). Keep one config: a jest.config.luau beside
another format, or beside one in .config/, is an error that names both files.
Configs can extend a shared base with extends:
export default defineConfig({
extends: "../../jest.shared.ts",
test: {
projects: ["ReplicatedStorage/shared"],
},
});Precedence: CLI flags > config file > extended config > defaults.
A Luau-only project can write its config as jest.config.luau. It has the same
fields as every other format and returns them as a table:
return {
placeFile = "game.rbxl",
test = {
projects = { "ReplicatedStorage/client" },
testMatch = { "**/*.spec" },
},
}The CLI reads the file without running it, so no Luau runtime is needed. The
returned value may hold only string, number and boolean literals, tables, ::
casts, and a defineConfig(...) call, which returns its argument. Statements
before the return, such as a require used only for types, are ignored. Any
other syntax in the returned value stops the load with an error that names the
file and line.
--config path/to/file.luau loads a Luau config explicitly. A relative
rootDir or workspace.root is relative to the Luau config's directory, as in
other formats. Mergeable fields cannot take the function form in Luau.
A string entry in test.projects can also point at a directory with a
jest.config.luau project config, which holds the per-project test fields at
its top level (displayName, testMatch, …). It is read the same way.
Two distinct buckets live at the root level. Jest passthrough fields live under
test: (see "Test fields" below).
Atomic to one invocation — these describe what the run targets and how the CLI
presents output, not how any individual package runs. In --workspace mode they
resolve as: CLI flag > unanimous per-package declaration > default. Mixed
per-package declarations error loudly.
| Field | What it does | Default |
|---|---|---|
backend |
"auto", "open-cloud", "studio", or "studio-cli" |
"auto" |
color |
Use ANSI colors in console output | true |
formatters |
Output formatters ("default", "agent", "json", "github-actions") |
["default"] |
gameOutput |
Write Game Output to a file — a path, or true for game-output.log under the root. In --workspace mode this is one grouped aggregate file across every package |
— |
outputFile |
Write the Jest result JSON — a path, or true for jest-output.log under the root. In --workspace mode this is the single merged result across every package |
— |
workspace.exclude |
Globs (workspace-root-relative) naming package directories an enumerated run must skip; --packages overrides it |
— |
workspace.gameOutput |
true to also emit per-package Game Output files under .jest-roblox/output/ (--workspace only) |
— |
workspace.outputFile |
true to also emit per-package result files under .jest-roblox/output/ (--workspace only) |
— |
parallel |
Concurrent Open Cloud sessions, or "auto" (= min(jobs, 3)). Each session costs one task create against Roblox's create limit. studio-cli ignores it and runs one session |
one session |
placeId |
Open Cloud place ID | — |
hiddenDesktop |
Run studio-cli Studio hidden so its windows and dialogs never reach yours (Windows, macOS; --no-hiddenDesktop opts out) |
true |
pluginsDirectory |
Absolute path of the folder Studio loads local plugins from, where studio-cli installs its runner plugin (also JEST_ROBLOX_PLUGINS_DIR) |
per-OS Studio plugins folder |
port |
WebSocket port for Studio backend | 3001 |
silent |
Suppress console output | false |
studioPath |
Roblox Studio executable for the studio-cli backend (auto-detected if unset; also --studioPath / JEST_ROBLOX_STUDIO_PATH) |
— |
universeId |
Open Cloud universe ID | — |
Loaded per package (directly or via extends: "../jest.shared.ts"). The
workspace-root config is NOT a source of truth for these — declare them in each
package's own jest.config or in a shared config that every package extends.
| Field | What it does | Default |
|---|---|---|
placeFile |
Path to your .rbxl file |
"./game.rbxl" |
timeout |
Max time for tests to run (ms) | 300000 (5 min) |
sourceMap |
Map Luau errors back to TypeScript (roblox-ts only) | true |
rojoProject |
Path to your Rojo project file | auto |
jestPath |
Where Jest lives in the DataModel | auto |
showLuau |
Show Luau code snippets in failure output | true |
coverageCache |
Reuse incrementally-instrumented coverage shadow dir between runs | true |
uploadCache |
Skip the place upload when the place file's bytes are unchanged | true |
luauRoots |
Where Luau files live for coverage instrumentation | auto from tsconfig outDir |
bootProbeTimeout |
How long the boot probe is given to prove a place version starts | 90000 (90 s) |
A coverage run does not mirror a whole luauRoot. It resolves your coverage
universe against the file system first and mirrors only the directories that
actually hold a covered file, which on a large project is a small fraction of
the tree. A Rojo $path mount that lands on such a directory is repointed at
the shadow; a mount above one is demoted instead — its $path moves to a copy
of that directory's own loose files and the narrowed directory is hung
underneath as an explicit child, so its unprobed siblings keep loading from your
outDir rather than being copied.
That makes a luauRoot above, at, or below a $path mount all work. Only a
root no mount reaches at all reports nothing, and the run says so by name.
Narrowing steps aside when it would not pay: a config whose covered files are scattered across a wide tree keeps most of the mount whichever way it is split, so the mount is taken whole instead of buying a project node per sibling.
timeout is the deadline Roblox is given for the script, not a hard cap on the
run. Roblox starts that clock when the script begins running — after the place
boots — so a run that overruns waits a short fixed allowance past the deadline
for Roblox's verdict, and reports the error Roblox gives rather than a poll
timeout.
On Open Cloud, timeout plus that allowance also bounds each task on the host.
The clock starts when the task is submitted, and the submit and every poll share
it. A task that stays PROCESSING fails when it runs out, not at Roblox's own
internal limit (about 11 minutes). A server-directed wait that cannot finish
before the deadline fails at once and names the wait and the time left.
bootProbeTimeout covers the Open Cloud backend's boot probe: after uploading a
place, it runs one trivial script pinned to the uploaded version and reads
game.PlaceVersion before dispatching tests. A matching response records the
version in .jest-roblox/upload-cache.json, so re-running the same place bytes
skips the probe (see uploadCache). Every test task is then submitted to that
same version, never to head, so another run saving the shared place cannot
change which bytes execute your tests. A pinned task may miss the warm-server
pool and pay a cold place boot.
The probe is a gate. If it reaches no terminal state within bootProbeTimeout,
one more probe is sent; if that one is lost too, or a probe reports another
version, the run stops as boot unverified (exit code 2) before any test task is
created, so a stalled version costs at most twice bootProbeTimeout. A lost
probe does not prove the place cannot load: Open Cloud can stall a task on a
version that boots. Nothing is cached, so the next run uploads and probes again.
The probe's submit and poll share one bootProbeTimeout deadline, with a short
script deadline. Set bootProbeTimeout to 0 to skip the probe; no new
upload-cache entry is written in that case.
Each logical execution creates exactly one Open Cloud task. Roblox can refuse
task creates for longer than its per-minute limit, and the refusal covers every
key, universe and place the account owns, so the backend never starts a
replacement task: a stalled task, a poll timeout or an uncertain create fails
the run instead of spending another create. Retries stay inside one request. A
task create is sent again only when the edge rate limit refused it before
Roblox's task service saw it; a 429 or 5xx from the task service itself is
final, because that attempt already counted against the create limit. A 429
fails with its code and its rate-limit headers; when its retry-after outlasts
the per-minute window, the error names the UTC time the account can create tasks
again. A create that loses its connection is also sent again, as it was before
replacement tasks existed. Place uploads retry 429, 5xx and transport errors;
task polls retry transient read failures. Test failures and terminal task errors
are never retried.
A test task that reaches its deadline without a terminal state, or a task create
refused with a 429, is a Roblox infrastructure failure, not a test result. The
run exits with code 3 and prints a Roblox Infrastructure Error banner with the
evidence to report to Roblox: the task path, the place version, the timeout,
Roblox's create and update times and the states seen, or for a 429 the unlock
time, code and rate-limit headers. The API key is removed from the evidence. A
Boot Probe that is lost stops the run as boot unverified instead.
A 404 during a run using a cached upload clears that cache entry for the next invocation. It does not restart the current wave, whose other tasks may already have executed.
Put these under test: { ... }.
| Field | What it does | Default |
|---|---|---|
projects |
Where to look for tests in the DataModel | one project per luauRoots mount |
testMatch |
Glob patterns that find test files | **/*.spec.ts, **/*.test.ts, etc. |
testPathIgnorePatterns |
Patterns to skip | /node_modules/, /dist/, /out/ |
setupFiles |
Scripts to run before the test environment loads | — |
setupFilesAfterEnv |
Scripts to run after the test environment loads | — |
projectTimeout |
Budget for one project's whole run (ms) | 60000 (60 s) |
verbose |
Show individual test results | false |
silent |
Suppress console output | false |
Three budgets nest, from the inside out: test.testTimeout bounds a single
test, test.projectTimeout bounds everything one project does in the runtime,
and the root timeout bounds the whole Open Cloud task. Each is set on its own:
projectTimeout lives in the config, has no CLI flag, and holds its default
whatever --timeout says, so raising the task deadline gives a slow project
nothing. Set projectTimeout on a single project (under its projects[N].test)
to give a slow one more room without raising it for the rest; set it to 0 to
turn the budget off.
The runner periodically yields to Roblox between completed tests and files, after teardown and coverage accounting. This lets Heartbeat and deferred callbacks progress during long sequences of synchronous tests, preventing the sequence from exhausting Roblox's script watchdog. A single non-yielding test still needs to stay within the engine's execution limit.
A project still running when its budget elapses is abandoned: its Jest run is
cancelled, anything it staged is torn down, and the run moves on to the next
project. It is reported as timed out rather than as a suite that threw — a
distinct line in every formatter, naming the budget you can raise and the last
test the run was seen in. For exit codes it counts as a failure (exit 1): a
project that never finished is not a pass, and greening on one would ship code
no test reached.
Without a budget one runaway project holds the whole task until Roblox ends it
on the root timeout, taking every project behind it down with it and reporting
DEADLINE_EXCEEDED — which names none of them.
Put these under test: { ... }.
| Field | What it does | Default |
|---|---|---|
collectCoverage |
Turn on coverage | false |
coverageDirectory |
Where to write coverage reports | "coverage" |
coverageReporters |
Which report formats to use | ["text", "lcov"] |
coverageThreshold |
Minimum coverage to pass | — |
coveragePathIgnorePatterns |
Files to leave out of coverage | test files, node_modules, rbxts_include |
collectCoverageFrom |
Globs for files to include in coverage | — |
coverageCopyIgnorePatterns |
Files to leave out of the coverage place | **/*.d.ts, **/*.d.ts.map, **/*.luau.map |
To leave out part of a file, put -- cov ignore next (// cov ignore next in
TypeScript) above a statement, or cov ignore if / cov ignore else above an
if. See the coverage reference in the skill for the full rules.
Note
Coverage uses vitest all / Istanbul semantics: every instrumented
file matching the include globs is reported, so a source file with no test
shows 0% (and fails coverageThreshold) instead of being silently
omitted. When collectCoverageFrom is unset for a multi-project run, the
include universe is derived from each project's include globs, excluding
*.spec/*.test and *.client/*.server entry-point scripts (which compile
to LocalScript/Script and can't be required, so no test can cover them).
In workspace mode collectCoverageFrom globs resolve against the package's
own rootDir, so a package at packages/foo writes src/**/*.ts for its own
sources no matter which directory the CLI was invoked from. Single and multi
runs anchor on the invocation directory.
Tip
The same universe decides what gets probes. A file outside it is copied into
the coverage place unprobed, so the run never carries hit counts the report
would discard — which is what keeps a large project under Open Cloud's 4 MiB
limit on a task's return value. Narrowing collectCoverageFrom is therefore
the lever to pull on OUTPUT_SIZE_LIMIT_EXCEEDED. Workspace mode has no
derived fallback, so a package that sets nothing probes its whole
luauRoots.
Workspace mode also caps what one task returns, so there it is the lever on run time rather than on the error: a task that fills its envelope stops and hands the rest to another, and fatter payloads mean more tasks.
coverageCopyIgnorePatterns is the other half of that: it decides what the
coverage place carries at all, not what the report covers. A coverage run
mirrors the covered directories into a shadow tree and builds the place from
that, and the default drops the three sidecars roblox-ts emits beside every
module. rojo mounts none of those extensions, and every reader of a source map
opens the one in outDir — the stack mapper resolves $path from your own rojo
project, not the synthesized one — so on a large project they are roughly three
quarters of the copied files.
Patterns match a path relative to its compiled root, directories included, and
they are matched exactly (no substring containment) because an over-match drops
something the runtime needs. An ignored path is never probed either, so a
pattern naming a .luau keeps that module out of the place entirely.
The mirror descends neither node_modules nor a dot-prefixed directory, which
is what every walk in the pipeline does — nothing inside one is probed, and
nothing inside one reaches the coverage place. A subtree the place has to load
belongs under a $path mount of its own, outside any luauRoot.
Narrowing never steps aside on a mount this list touches. A pattern naming the
mount's only covered module leaves nothing to narrow towards, and taking the
mount whole is what keeps the module out: the shadow is the one tree it is
missing from, so a mount left on your outDir would serve it anyway.
Declare a function to keep the defaults and add to them:
export default defineConfig({
test: {
coverageCopyIgnorePatterns: (defaults) => {
return [...defaults, "**/*.tsbuildinfo"];
},
},
});Passing an array replaces the defaults outright; [] copies everything.
projects can be strings (DataModel paths) or objects with per-project
overrides:
import { defineConfig, defineProject } from "@isentinel/jest-roblox";
export default defineConfig({
placeFile: "./game.rbxl",
test: {
projects: [
defineProject({
test: {
displayName: { name: "client", color: "magenta" },
include: ["**/*.spec.ts"],
mockDataModel: true,
outDir: "out/src/client",
},
}),
defineProject({
test: {
displayName: { name: "server", color: "white" },
include: ["**/*.spec.ts"],
outDir: "out/src/server",
},
}),
],
},
});import { defineConfig } from "@isentinel/jest-roblox";
export default defineConfig({
backend: "open-cloud",
jestPath: "ReplicatedStorage/Packages/Jest",
placeFile: "./game.rbxl",
test: {
collectCoverage: true,
coverageThreshold: {
branches: 70,
functions: 80,
statements: 80,
},
projects: ["ReplicatedStorage/client", "ServerScriptService/server"],
},
timeout: 60000,
});Three ways to run tests, plus an auto-pick:
--backend auto (the default) runs via studio-cli when Roblox Studio is
installed, and via Open Cloud otherwise (see Open Cloud below for the
credentials it needs). Studio counts as installed when studioPath or
JEST_ROBLOX_STUDIO_PATH is set, or when per-OS discovery finds it (see Studio
CLI below).
The choice is made once, before the run. A studio-cli run that fails — Studio
not logged in, the plugin missing — fails the run; it does not retry on Open
Cloud. Auto never selects the attached studio backend: request it with
--backend studio.
Uploads your place file to Roblox and polls for results.
You need these environment variables:
| Variable | What it is |
|---|---|
ROBLOX_OPEN_CLOUD_API_KEY |
Your Open Cloud API key |
ROBLOX_UNIVERSE_ID |
The universe to run tests in |
ROBLOX_PLACE_ID |
The place to run tests in |
Prefix any of the above with
JEST_(e.g.JEST_ROBLOX_PLACE_ID) to override the unprefixed value. Use theJEST_-prefixed form when the generic names collide with other tooling.
Create the API key in the Creator Dashboard against the target universe, then
grant it the scopes below. A 403 at runtime surfaces as a PermissionError
with the missing scope name.
Always required:
| Scope | What it's for |
|---|---|
universe-places:write |
Publish the built .rbxl as a new place version |
universe.place.luau-execution-session:write |
Start the Luau session that runs the tests |
A sharding --workspace run — --parallel auto or an explicit count above 1 —
additionally requires the queue scopes for work-stealing across concurrent
sessions. Without them the run warns and falls back to static bucketing — the
same shard count, each session holding a fixed share it cannot rebalance:
| Scope | What it's for |
|---|---|
memory-store.queue:add / :dequeue / :discard |
Work-stealing queue across concurrent sessions |
The sorted-map scopes support live per-package results:
| Scope | What it's for |
|---|---|
memory-store.sorted-map:read / :write |
Result streaming |
Streaming is enabled by default and disabled only for --silent,
--formatters json, and --formatters agent (without --verbose).
--formatters agent --verbose re-enables streaming and therefore still needs
the sorted-map scopes; --formatters github-actions also streams.
An Open Cloud run builds its code into the place it uploads, and every task — the boot probe and the tests — runs against the exact version that upload returned. A shared place has no head this run can trust: another run may save over it at any moment, so the version is the only thing that names this run's bytes.
No option sends the code beside a code-free place. That transport needs a place no other run can change, which only a future exclusive place allocator can grant.
Connects to Roblox Studio over WebSocket. Faster than Open Cloud (no upload step), but Studio must be open with the plugin running. Studio doesn't expose which place is open, so multiple concurrent projects aren't supported yet.
While no CLI is listening, the plugin retries at growing intervals, up to 5 seconds apart, so a run can wait a few seconds for the plugin to connect.
Note
For --coverage, prefer --backend open-cloud since the coverage
output is built to a separate output under .jest-roblox/place/ that is
likely not the studio place being served.
Install the plugin with Drillbit:
Create a file named drillbit.toml in your project's directory.
[plugins.jest_roblox]
github = "https://github.com/christopher-buss/jest-roblox-cli/releases/download/v0.4.11/JestRobloxRunner.rbxm"Then run drillbit and it will download the plugin and install it in Studio for
you.
Or download JestRobloxRunner.rbxm from the
latest release
and drop it into your Studio plugins folder.
Studio runs every plugin in your plugins folder, so a leftover copy of an older
JestRobloxRunner opens its own connection alongside the current one. Each
connection announces its protocol version, and the CLI runs on one that speaks
the protocol this release speaks — the others are left alone. The release
numbers need not match; the protocol is the compatibility contract, so a plugin
from a neighbouring release that speaks the same protocol serves the run.
When no connection speaks it, the run stops before building a place and names every connection it found:
No compatible jest-roblox Studio plugin. This CLI speaks protocol v8, and the 2 plugin connection(s) on this port report:
- JestRobloxRunner 0.3.18 (protocol v6)
- a plugin that sent no handshake (it predates the handshake entirely)
Install the JestRobloxRunner.rbxm shipped with jest-roblox 0.4.11, and remove the other copies from your Studio plugins folder.
This is an error even when Open Cloud credentials are set: a plugin that cannot serve the run is something to fix, not a reason to switch backend.
--backend studio-cli installs its own plugin and needs none of this; see
below.
--backend studio-cli owns the whole Studio lifecycle: it builds its own place,
launches Roblox Studio headless via Studio's --task RunScript interface,
drives its plugin's Run mode, reads the result from Studio's output log, and
quits Studio. No API key, no upload, no pre-opened editor — you just need Studio
installed (logged in) and rojo on PATH. It spawns its
own isolated Studio instance, so any editor you already have open is untouched.
You never install or update the plugin for studio-cli. Before each run the CLI
checks the Studio plugins folder for the plugin built from its own source —
JestRobloxRunner.cli-g<generation>-<version>-<hash>.rbxm — and builds it with
rojo when it is missing. The file stays for later runs, so only the first run
after an upgrade pays for the build. The CLI records when each of its files was
last used in its own folder (%LOCALAPPDATA%\jest-roblox on Windows,
~/Library/Caches/jest-roblox on macOS), deletes its files for other versions
once they have gone unused for a day, deletes files of an older generation at
once, and never touches a plugin you installed yourself. Each run asks for its
own plugin by name, so any other copy in the folder stands down; a copy that
predates the key check can still answer first, and the error then names the
files to remove.
The plugins folder is %LOCALAPPDATA%\Roblox\Plugins on Windows and
~/Documents/Roblox/Plugins on macOS. If Studio loads local plugins from a
different folder, set pluginsDirectory (config key) or
JEST_ROBLOX_PLUGINS_DIR to its absolute path; the config key wins. The CLI
then installs its plugin there and sweeps only that folder.
auto selects it whenever Studio is installed. Studio is auto-discovered
per-OS; override the executable with studioPath (config key), --studioPath,
or JEST_ROBLOX_STUDIO_PATH. JEST_ROBLOX_STUDIO_DISCOVERY takes on or
off; unset or empty means on, and any other value is an error. With off,
discovery finds no Studio: auto then picks Open Cloud unless a Studio path is
set. The backend is serial: it ignores --parallel and runs one session.
timeout starts once Studio has opened the place and begun the run, as on Open
Cloud, so a slow Studio launch does not eat into the test budget. The launch has
its own 45s window; on a slow machine, raise it with
JEST_ROBLOX_STUDIO_BOOT_TIMEOUT (milliseconds). A logged-out Studio stops at
its login screen and never starts the run, so the launch window runs out and the
error names this cause: open Studio, log in, and run again.
Pass --headed to show the Studio window during the run instead of the default
hidden one — useful for watching a slow run or a hang (Studio still self-quits
when tests finish, so a fast run just flashes). It is a per-run debugging flag,
CLI-only and inert on every other backend.
Studio runs hidden by default on Windows and macOS (hiddenDesktop). On Windows
it runs on a separate, invisible desktop: no window or dialog reaches your
desktop or takes focus. A dialog that waits for input stays invisible; the
graceful-shutdown cap kills Studio if one blocks the close. On macOS Studio
opens without taking focus and is hidden as soon as it shows itself; its windows
can appear behind the front app for a moment while it loads. Clicking it shows
it. Set hiddenDesktop: false (or pass --no-hiddenDesktop) to opt out.
--headed overrides it for one run, showing the window of a run that hangs.
Elsewhere the default does nothing, and an explicit hiddenDesktop: true is
ignored with a warning.
Once the result lands, Studio is shut down gracefully: it is allowed to close
the place — running any edit-mode plugin BindToClose handlers and freeing the
place lock — and is then killed the instant the lock releases, skipping Studio's
slow telemetry teardown. The shutdown is decoupled from the result, so pass/fail
prints immediately and the process exits once teardown finishes.
Unlike the attached studio backend, --coverage works here: studio-cli opens
the Coverage-Instrumented Place instead of the Clean Place, so the report
universe, thresholds, reporters, and exclusions behave identically to the
open-cloud backend (including the all-files semantics where an untested included
file reports 0% and fails coverageThreshold).
Note
studio-cli needs a logged-in Studio. On a CI machine with Studio installed,
pass --backend open-cloud to keep CI on Open Cloud.
--experimental-vm-parallel [n] runs a multi-project suite across n Luau VMs
inside the one Studio session instead of one project after another. Bare, it
asks for one VM per project, capped at the four Actor hosts the plugin ships
(they are declared in its rojo project, so the pool is fixed when the plugin is
built); an explicit n above that cap is rejected rather than quietly reduced.
Both Studio backends drive it (studio and studio-cli) — Open Cloud rejects
it, because an Open Cloud session runs no scripts to host a second VM (use
--parallel there to shard across sessions).
In workspace mode the split is per package: packages still run one after another, each staged into the DataModel on its own, and a package's projects run across the VMs once it is staged. A workspace of single-project packages gains nothing. The package's Game Output is one capture, filed under its first project.
Each host is an Actor in the plugin's own tree, so each project gets its own
_G, its own module cache, and its own copy of Jest. That is what makes the
overlap safe where running the projects concurrently in one VM is not: Jest
keeps its run state (the circus describe tree, expect's matcher state) in
VM-globals.
Two caveats come with it, both inherent rather than temporary:
- Game output becomes batch-scoped.
LogService.MessageOutreports every message to every listener with no source identity, so once projects overlap nothing can say which project printed a line. The--gameOutputfile writes one group labelled"project": "(all projects)","scope": "batch"instead of one group per project. The label follows what the run did rather than what it asked for: a run that collapses to a single VM (one project, or--experimental-vm-parallel 1), or one where no VM host was ready and the plugin fell back to running the projects in turn, keeps per-project groups. - The DataModel is still shared. Projects that mutate
Workspace,ReplicatedStorage,Players, or a DataStore mock conflict when they overlap, however the runner behaves. Turn this on for suites you know are DataModel-disjoint.
Projects whose runtime jest.config mounts nest — ReplicatedStorage and
ReplicatedStorage/Foo — are always assigned to the same host, and so still run
one after another. Their config stubs must never sit in the DataModel at the
same time, or Jest's parent-traversal config lookup resolves the wrong project's
config.
A host that fails or stops answering yields ExecutionError entries for its own
projects only; the other hosts' results come back normally. The plugin stops
waiting inside the run timeout it was given (--timeout, 300s by default), so
those partial results still reach you rather than arriving after the CLI has
given up.
Run tests across multiple packages in a pnpm workspace in a single invocation.
Works on every backend: Open Cloud (fans packages across parallel tasks),
studio-cli (one self-launched Studio process drives every package, no
sharding), and the attached studio backend (runs the workspace inside an open
Studio — handy for debugging the flow). studio-cli is serial, so it ignores
--parallel and runs one session.
Note
Package discovery uses one of two sources. By default it takes the pnpm
workspace: the project list pnpm records at install time, falling back to
pnpm-workspace.yaml when that record is missing or older than the file. Both
count the root package.json as a package whether or not packages: lists
., matching pnpm. A package in a dot-directory resolves only from the
recorded list, so run pnpm install after adding one. Alternatively, declare a
workspace block in your jest config (see
Workspaces without pnpm) to enumerate packages by
glob — this works in Luau-only, npm, and yarn repos. Either source honours !
exclusion entries, and either way a package joins the run by carrying a
jest.config.*; a library with no tests is not selected.
--affected-since always delegates change detection to turbo or
nx and is not yet wired for the workspace.packages source. When using Nx,
each project's Nx name must match the package.json name field —
--affected-since returns Nx project names and looks them up against the
package list, so a mismatch surfaces as
Package "<name>" not found in workspace.
A bare --workspace runs every package in the workspace. The two selection
flags narrow that set rather than enabling it, as does naming a file (see
Naming files):
# Every package
jest-roblox --workspace
# Specific packages
jest-roblox --workspace --packages @scope/pkg-a,@scope/pkg-b
# Everything changed since a git ref (via turbo/nx affected)
jest-roblox --workspace --affected-since main--packages and --affected-since are mutually exclusive, and either flag
requires --workspace. A --workspace that selects nothing exits 2 — an empty
workspace is a configuration problem, while --affected-since finding nothing
is a clean run and exits 0.
A workspace run stages every selected package into one synthesized place, under
ServerStorage.__pkg_stage, one child per package. A $path the stage mounts
twice or more — typically the dependency tree every package shares — is built
once into a __shared pool beside them, and each occurrence becomes a marker
Folder naming its pooled entry, which the run resolves by cloning that entry.
Every package still receives its own fresh clone of every dependency, so only
the size of the place changes. A run outside --workspace stages nothing and is
unaffected.
A positional file narrows a workspace run the same way it narrows a
single-package one, and it narrows all the way down: only the packages and
projects whose include roots own the file are staged into the synthesized
place, and each runs that file alone.
# Only the package that owns this file, only this file
jest-roblox --workspace src/shared/dropdown.spec.tsPaths are relative to the directory you run from, so the same argument works
from a package subdirectory. (Outside --workspace the base is the config's
rootDir instead — a workspace has no single one to use.) A file no package
owns is an error listing the include roots that were searched: naming a file
that nothing matches is a typo, not a clean run. Ownership is by include root,
so a file under a root that several projects include selects all of them.
Naming a *.spec-d.ts selects the type pass instead, and naming a runtime spec
leaves the package's Type Tests out. --project still applies first: it decides
which projects may run at all, and the file picks from what survives — so naming
a file none of those projects owns is the same error, where outside
--workspace the named projects would each be handed the file regardless.
workspace.exclude keeps a package out of a run that did not name it. Globs are
relative to the workspace root and match package directories:
export default defineConfig({
workspace: {
exclude: ["test/fixtures/**"],
},
});This is for packages the package manager has to install but the test run must
never pick up — a test fixture with real workspace:* dependencies has to stay
a workspace member, and dropping it from pnpm-workspace.yaml would break its
dependency links.
The exclude applies to enumeration only: a bare --workspace and
--affected-since both honour it, while --packages @scope/fixture still runs
it, because naming a package is asking for it.
--bail stops the run at the first failing package instead of testing the rest:
jest-roblox --workspace --packages @scope/pkg-a,@scope/pkg-b --bailThe packages that never ran are left out of the report, which ends with how far the run got:
Bailed after 2 packages, 5 not run
A package counts as run once any of its projects ran, so a package the bail caught part-way through is not reported as skipped.
The exit code is the usual 1 — a bail is a test failure that ended the run
early, not an error in its own right.
Under --parallel with work-stealing, the task that fails announces it through
a MemoryStore signal map the wave shares, and its siblings stop before taking
their next package. Open Cloud cannot cancel a task from outside, so a package
already running finishes and reports; the bail stops the ones after it.
That map is written and read inside the Roblox session, so it needs no extra
API-key scopes — the memory-store.sorted-map scopes below are for the CLI's
own streaming reads, which --bail does not require.
Without the queue scopes there is no wave to broadcast to: each task holds a fixed bucket and bails only its own. The run still stops at the first failing package in every bucket and reports the rest as not run, but a sibling bucket runs its whole share out first.
A Studio backend (studio, studio-cli) runs one package at a time, so it
stops at the first failing package with nothing left running. It needs a Studio
plugin from this release or later; an older plugin runs every package.
Workspace mode only. --bail without --workspace is rejected rather than
quietly running the whole batch.
This is not Jest's bail. test.bail in your config still counts failing test
suites inside a single package and is passed through to Jest untouched.
pnpm-workspace.yaml isn't required. Declare a workspace block in a shared
config and have every package extend it:
// packages/testing/jest.shared.ts
import { defineConfig } from "@isentinel/jest-roblox";
export default defineConfig({
workspace: {
packages: ["packages/*"], // globs relative to root
root: "../..", // relative to THIS file; resolved at load
},
// shared jest options…
});// packages/foo/jest.config.ts
export default { extends: "../testing/jest.shared.ts" };workspace.root and workspace.packages must be declared together. root is
resolved to an absolute path relative to the file that declares it (the shared
config), so it points at the same directory no matter which package you run
from. Each glob in packages selects directories that contain a
jest.config.*; the package name comes from package.json#name, falling back
to the directory name (so Luau-only packages need no package.json). A . glob
selects the workspace root itself, the same as a packages: - . entry in
pnpm-workspace.yaml. Every selected package must resolve the same
workspace.packages/root — inheriting from one shared config guarantees this,
and a package that overrides or omits it fails the run.
Run from inside any package as usual. To run from a directory with no resolvable
jest config (e.g. the repo root), either point at the shared config with
--workspace-root:
jest-roblox --workspace --packages foo --workspace-root packages/testingor add a re-export at the repo root so the config is discovered there:
// jest.config.ts (repo root)
export { default } from "./packages/testing/jest.shared.ts";Coverage reports per package. Each opted-in package gets its own report, built
from its own collectCoverageFrom and coveragePathIgnorePatterns, rendered by
its own coverageReporters, into its own <rootDir>/<coverageDirectory> — its
rootDir being the package directory, so the report lands beside the package.
Coverage is opt-in per package (each package's own collectCoverage), and
coverageThreshold is enforced per package: every opted-in package is gated
against its own files, by the threshold it declared itself. A package that
declares none is not gated — there is no run-level threshold to inherit, since
the file at the directory you happen to run from is not a source of truth. There
is no pooled cross-package gate either, so one package's high coverage cannot
mask another's shortfall. Declaring a threshold without collectCoverage prints
a warning: it cannot be enforced without instrumentation.
Game Output has two independent sinks. Setting gameOutput (a path, or true)
writes one grouped aggregate file at the workspace root —
[{ package, project, entries }], one group per (package, project) that ran.
Setting workspace.gameOutput: true writes a per-package file per (package,
project) under .jest-roblox/output/. Either, both, or neither may be set; with
both, humans see the aggregate announced and agents see the per-package paths.
Each capture opens before its package is materialized and closes after Jest's teardown, and holds only that package's output (a package with several projects carries the materialize-time slice on its first project's entry; Destroy-time output from the reset between packages is attributed to neither side). A package that fails also has its Game Output printed under its own section of the terminal report, so no file needs opening to tell which package printed which warning.
outputFile (the Jest result JSON) follows the same two-sink model:
outputFile (a path, or true) writes one merged result at the workspace root,
and workspace.outputFile: true writes a per-package result file per (package,
project) under .jest-roblox/output/.
| Flag | What it does |
|---|---|
--backend <type> |
Choose auto, open-cloud, studio, or studio-cli |
--port <n> |
WebSocket port for Studio |
--studioPath <path> |
Roblox Studio executable for studio-cli (auto-detected if unset) |
--headed |
Show the Studio window during the run (studio-cli only; default: hidden) |
--no-hiddenDesktop |
Let Studio open on your desktop (studio-cli only; default: hidden on Windows and macOS) |
--config <path> |
Path to config file |
--testPathPattern <regex> |
Filter test files by path |
-t, --testNamePattern <regex> |
Filter tests by name |
--formatters <name> |
Output formatter: default, agent, json, or github-actions (repeatable) |
--outputFile <path> |
Write results to a file |
--gameOutput <path> |
Write game print/warn/error to a file |
--coverage |
Collect coverage |
--no-coverage |
Disable coverage for this run, even when enabled in config |
--coverageDirectory <path> |
Where to put coverage reports |
--coverageReporters <name> |
Which report format to use (repeatable) |
--collectCoverageFrom <glob> |
Globs for files to include in coverage (repeatable) |
--no-show-luau |
Hide Luau code in failure output |
-u, --updateSnapshot |
Update snapshot files |
--sourceMap |
Map Luau errors to TypeScript (roblox-ts only) |
--rojoProject <path> |
Path to Rojo project file |
--timeout <ms> |
Max time for tests to run |
--passWithNoTests |
Exit 0 when no test files are found |
--verbose |
Show each test result |
--silent |
Hide all output |
--no-color |
Turn off colors |
--no-coverage-cache |
Force a clean coverage re-instrumentation |
--no-upload-cache |
Always upload the place, even when its bytes are unchanged |
--parallel [n] |
Open Cloud concurrent sessions, or auto (= min(jobs, 3)); one by default, each costing one task create against Roblox's create limit; ignored on studio-cli |
--experimental-vm-parallel [n] |
Studio-only: run the projects across n Luau VMs in one session (see Experimental: in-session VM parallelism) |
--project <name> |
Filter which named projects to run (repeatable) |
--setupFiles <path> |
Script to run before env (repeatable) |
--setupFilesAfterEnv <path> |
Script to run after env (repeatable) |
--typecheck |
Run type tests too |
--typecheckOnly |
Run only type tests |
--typecheckTsconfig <path> |
tsconfig for type tests |
--workspace |
Run every package in the workspace; narrow it with --packages, --affected-since, or a positional file (see Workspace mode) |
--bail |
Workspace mode: stop at the first failing package (see Failing fast) |
--packages <names> |
Comma-separated package names; narrows a workspace run |
--affected-since <ref> |
Run only packages affected since a git ref (workspace mode) |
--apiKey <key> |
Open Cloud API key (prefer env vars in CI — visible in process listings) |
--universeId <id> |
Target universe ID (Open Cloud) |
--placeId <id> |
Target place ID (Open Cloud) |
- Finds files matching
testMatchpatterns - Builds a
.rbxlvia Rojo - Sends the place to Roblox (Open Cloud upload or Studio WebSocket)
- Parses Jest JSON output from the session
- Maps Luau line numbers to TypeScript via source maps (roblox-ts only)
- Prints results
Every step up to the tests announces itself under the RUN header, with what it
is working on — the size of the place going up, the version that booted, the
number of projects dispatched — so a slow one says which it is. A terminal gets
one block, repainted in place with a running duration; a pipe, a redirect and a
CI log get a line as each step starts and another as it ends. Either way the
block stops there and stays put: the results, the summary and the coverage
report are the run saying where it is from then on. --silent,
--formatters json and --formatters agent report no stages at all.
Every run also writes what it printed to .jest-roblox/last-run.log under the
root, overwritten per run, whatever the mode or exit code. An agent that read
the output through | tail and missed a line reads the file instead of paying
for the run again; the agent formatter says so on stderr once the run ends.
Note
Coverage adds extra steps: copy Luau files, insert tracking probes, build a separate place file, then map hit counts back to source. For roblox-ts, this goes through source maps to report TypeScript lines.
Default testMatch patterns (configurable):
- TypeScript:
*.spec.ts,*.test.ts,*.spec.tsx,*.test.tsx - Luau:
*.spec.lua,*.test.lua,*.spec.luau,*.test.luau - Type tests:
*.spec-d.ts,*.test-d.ts,*.spec-d.luau,*.test-d.luau
Type Tests only run on the host. They never run in Roblox: the Roblox-side Jest
config leaves them out of testMatch and ignores any *.spec-d or *.test-d
module that a broader pattern of yours would match.
Luau Type Tests are *.spec-d.luau or *.test-d.luau files beside your runtime
*.spec.luau / *.test.luau specs. They are checked on the host by
lute check, so lute must be on PATH; no place is built for them. Enable
them like TypeScript Type Tests, with test.typecheck.enabled, --typecheck,
or --typecheckOnly (which skips Runtime Tests and makes no Roblox call). The
include globs derive from the runtime ones (.spec.luau → .spec-d.luau)
unless test.typecheck.include is set.
Each file must start with --!strict. A file without it fails as a whole,
because non-strict Luau reports no type errors. Projects that share a root
directory are checked by one lute process; tsconfig does not apply.
A -- @expect-error line comment asserts that the next statement fails type
checking:
it("rejects a string field", function()
-- @expect-error
local _p: Point = {
x = 1,
y = "two",
}
end)- The directive targets the first statement that starts on a later line, over that statement's whole span, so an error reported on a field inside a multi-line literal still matches.
- Every type error that starts inside the target is expected and does not fail the test.
- A directive that matches no error fails with
unused @expect-error, in theitthat contains it or at file level. - It checks that an error exists, not what the error says.
- Only comments count:
@expect-errorinside a string is ignored.
The package ships a Luau module for type assertions. Alias it in your .luaurc:
{
"languageMode": "strict",
"aliases": {
"jest": "node_modules/@isentinel/jest-roblox/lib"
}
}The alias path is relative to the .luaurc. Then require it as
@jest/typecheck:
--!strict
local t = require("@jest/typecheck")
type GamePass = { name: string, price: number? }
local function getPass(): GamePass
return { name = "VIP" }
end
t.describe("GamePass", function()
t.it("has a name and an optional price", function()
t.expectType<<GamePass>>():toEqualType<<{ name: string, price: number? }>>()
end)
t.it("is what getPass returns", function()
t.expectTypeOf(getPass()):toEqualType<<GamePass>>()
end)
end)describeanditdo nothing at runtime; they name the tests.expectType<<T>>()takes a type;expectTypeOf(value)takes the type of a value.:toEqualType<<U>>()and:toEqualTypeOf(value)assert the types are equal..never:toEqualType<<U>>()and.never:toEqualTypeOf(value)assert the types differ. The name isneverbecausenotis a Luau keyword, so.notis a syntax error.:toExtend<<U>>()asserts the subject is assignable toU, using Luau's own subtyping and its error message. There is no negated form; put-- @expect-errorabove atoExtendthat must fail.
Equality is structural: union and intersection order, property order and
parameter names do not matter, boolean equals true | false, and generic
functions match when their generics differ only in name. Read-only and
read-write properties differ, as do any and unknown. Luau gives every
function expression a ...any parameter tail; equality ignores it.
A failed assertion reports both types:
Expected: { x: number, y: string }
Received: { x: number, y: number }
A failed .never assertion reports Expected a type other than: <U>.
A multi-line type literal inside <<...>>() is a Luau syntax error. Declare an
alias first:
type Expected = {
name: string,
price: number?,
}
t.expectType<<GamePass>>():toEqualType<<Expected>>()See CONTRIBUTING.md.
The bin prefers TypeScript source when present. Run it with
node --conditions=source bin/jest-roblox.js to resolve workspace dependencies
from source too. pnpm build:bundle still generates the Luau runner and
materializer that the source imports.
Source consumers of @isentinel/jest-roblox/artifacts can register the
synchronous Node hooks exported by @isentinel/jest-roblox/loader before
dynamically importing that API. These hooks load raw Luau and the CLI's virtual
asset modules.
Maintainers can run the Release binaries workflow for an existing release tag:
gh workflow run release-binaries.yaml --repo christopher-buss/jest-roblox-cli --ref main --field tag=v0.4.11The workflow builds from that tag with LF checkout and uploads missing Linux x64, macOS arm64, and Windows x64 archives. Existing assets stay in place. It does not publish to npm or change the release tag.
MIT
