@isentinel/jest-roblox 0.2.7 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isentinel/jest-roblox",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
4
4
  "description": "Jest-compatible CLI for running roblox-ts tests via Roblox Open Cloud",
5
5
  "keywords": [
6
6
  "jest",
@@ -35,17 +35,18 @@
35
35
  "dist",
36
36
  "loaders",
37
37
  "plugin",
38
+ "!dist/sea",
38
39
  "!dist/**/*.map",
39
40
  "!dist/**/*.tsbuildinfo"
40
41
  ],
41
42
  "dependencies": {
43
+ "@bedrock-rbx/ocale": "0.1.0-beta.15",
42
44
  "@jridgewell/trace-mapping": "0.3.31",
43
- "@roblox-ts/rojo-resolver": "1.1.0",
44
45
  "arktype": "2.2.0",
45
- "c12": "4.0.0-beta.4",
46
+ "c12": "4.0.0-beta.5",
46
47
  "confbox": "0.2.4",
47
- "defu": "6.1.6",
48
- "get-tsconfig": "4.13.7",
48
+ "defu": "6.1.7",
49
+ "get-tsconfig": "4.14.0",
49
50
  "highlight.js": "11.11.1",
50
51
  "istanbul-lib-coverage": "3.2.2",
51
52
  "istanbul-lib-report": "3.0.1",
@@ -54,50 +55,53 @@
54
55
  "picomatch": "4.0.4",
55
56
  "std-env": "4.0.0",
56
57
  "tinyrainbow": "3.1.0",
57
- "ws": "8.20.0"
58
+ "ws": "8.20.1"
58
59
  },
59
60
  "devDependencies": {
60
- "@isentinel/eslint-config": "5.0.0-beta.9",
61
+ "@isentinel/eslint-config": "5.0.0-beta.11",
62
+ "@isentinel/roblox-ts": "4.0.2",
61
63
  "@isentinel/tsconfig": "1.2.0",
62
- "@oxc-project/types": "0.120.0",
64
+ "@isentinel/weld": "0.2.0",
65
+ "@oxc-project/types": "0.123.0",
63
66
  "@rbxts/jest": "3.13.3-ts.1",
64
- "@rbxts/types": "1.0.914",
67
+ "@rbxts/types": "1.0.920",
65
68
  "@total-typescript/shoehorn": "0.1.2",
66
- "@tsdown/exe": "0.21.7",
69
+ "@tsdown/exe": "0.22.0",
67
70
  "@types/istanbul-lib-coverage": "2.0.6",
68
71
  "@types/istanbul-lib-report": "3.0.3",
69
72
  "@types/istanbul-reports": "3.0.4",
70
- "@types/node": "24.12.0",
71
- "@types/picomatch": "4.0.2",
73
+ "@types/node": "24.12.3",
74
+ "@types/picomatch": "4.0.3",
72
75
  "@types/ws": "8.18.1",
73
- "@typescript/native-preview": "7.0.0-dev.20260325.1",
74
- "@vitest/coverage-v8": "4.1.2",
75
- "@vitest/eslint-plugin": "1.6.14",
76
+ "@typescript/native-preview": "7.0.0-dev.20260512.1",
77
+ "@vitest/coverage-v8": "4.1.5",
78
+ "@vitest/eslint-plugin": "1.6.17",
76
79
  "better-typescript-lib": "2.12.0",
77
- "bumpp": "11.0.1",
80
+ "bumpp": "11.1.0",
78
81
  "eslint": "9.39.4",
79
82
  "eslint-plugin-jest-extended": "3.0.1",
80
83
  "eslint-plugin-n": "17.24.0",
81
84
  "eslint-plugin-pnpm": "1.6.0",
82
85
  "jest-extended": "7.0.0",
83
- "memfs": "4.57.1",
84
- "publint": "0.3.18",
85
- "tsdown": "0.21.7",
86
+ "memfs": "4.57.2",
87
+ "publint": "0.3.21",
88
+ "tsdown": "0.22.0",
86
89
  "type-fest": "5.5.0",
87
- "typescript": "5.9.3",
88
- "vitest": "4.1.2",
89
- "@isentinel/luau-ast": "0.1.0",
90
+ "typescript": "6.0.3",
91
+ "vitest": "4.1.5",
90
92
  "@isentinel/rojo-utils": "0.1.0",
93
+ "@isentinel/luau-ast": "0.1.0",
91
94
  "@isentinel/roblox-runner": "0.1.0"
92
95
  },
93
96
  "engines": {
94
97
  "node": ">=24.10.0"
95
98
  },
96
99
  "scripts": {
100
+ "bench": "vitest bench --project unit",
97
101
  "build": "pnpm build:packages && pnpm build:bundle && tsdown && pnpm build:plugin",
98
- "build:bundle": "darklua process -c .darklua-bundle.json luau/entry.luau src/test-runner.bundled.luau",
102
+ "build:bundle": "weld",
99
103
  "build:packages": "pnpm -r --filter !@isentinel/jest-roblox run build",
100
- "build:plugin": "rm -rf plugin/out && darklua process -c .darklua-plugin.json luau/ plugin/out/shared/ && rojo build plugin/plugin.project.json -o plugin/JestRobloxRunner.rbxm",
104
+ "build:plugin": "rojo build plugin/plugin.project.json -o plugin/JestRobloxRunner.rbxm",
101
105
  "lint": "eslint --cache",
102
106
  "lint:ci": "eslint --cache --cache-strategy content",
103
107
  "release": "bumpp",
Binary file
@@ -3,7 +3,7 @@
3
3
  "tree": {
4
4
  "$path": "src",
5
5
  "shared": {
6
- "$path": "out/shared"
6
+ "$path": "../luau"
7
7
  }
8
8
  }
9
9
  }
@@ -11,6 +11,13 @@ end
11
11
  local WEBSOCKET_URL = "ws://localhost:3001"
12
12
  local RECONNECT_DELAY = 0.1
13
13
 
14
+ -- Plugin/CLI protocol version. Must match `STUDIO_PROTOCOL_VERSION` in
15
+ -- `src/backends/studio.ts`. Bumped when the runtime contract changes (e.g.
16
+ -- runtime-injection payload shape). The plugin rejects mismatched CLI
17
+ -- versions with a `version_mismatch` response so users see a clear upgrade
18
+ -- message rather than running with stale behaviour or an opaque timeout.
19
+ local PROTOCOL_VERSION = 2
20
+
14
21
  local IsDebug = ReplicatedStorage:GetAttribute("JEST_ROBLOX_DEBUG") == true
15
22
  ReplicatedStorage:GetAttributeChangedSignal("JEST_ROBLOX_DEBUG"):Connect(function(): ()
16
23
  IsDebug = ReplicatedStorage:GetAttribute("JEST_ROBLOX_DEBUG") == true
@@ -96,12 +103,38 @@ local function connect(): ()
96
103
  end
97
104
 
98
105
  if message.action == "run_tests" then
106
+ -- Version handshake: reject a missing or mismatched
107
+ -- `protocolVersion` synchronously so the user sees an
108
+ -- upgrade message rather than stale-semantics behaviour.
109
+ local clientVersion = if typeof(message.protocolVersion) == "number"
110
+ then message.protocolVersion
111
+ else nil
112
+ if clientVersion ~= PROTOCOL_VERSION then
113
+ warn(
114
+ "[jest-roblox] Protocol version mismatch — CLI v"
115
+ .. tostring(clientVersion)
116
+ .. ", plugin v"
117
+ .. tostring(PROTOCOL_VERSION)
118
+ )
119
+ ws:Send(HttpService:JSONEncode({
120
+ type = "version_mismatch",
121
+ actualVersion = PROTOCOL_VERSION,
122
+ expectedVersion = clientVersion or 0,
123
+ request_id = message.request_id,
124
+ }))
125
+ return
126
+ end
127
+
99
128
  print("[jest-roblox] Running tests via StudioTestService...")
100
129
 
101
130
  local runOk, result = pcall(function(): any
102
131
  return StudioTestService:ExecuteRunModeAsync({
103
132
  test = true,
104
133
  config = message.config,
134
+ -- Filtered injection targets (parallel to configs)
135
+ -- so the Run Mode runner skips mounts that already
136
+ -- have a user-authored jest.config on disk.
137
+ runtimeStubMounts = message.runtimeStubMounts,
105
138
  })
106
139
  end)
107
140
 
@@ -109,6 +142,7 @@ local function connect(): ()
109
142
  warn("[jest-roblox] ExecuteRunModeAsync failed: " .. tostring(result))
110
143
  ws:Send(HttpService:JSONEncode({
111
144
  type = "results",
145
+ protocolVersion = PROTOCOL_VERSION,
112
146
  request_id = message.request_id,
113
147
  jestOutput = HttpService:JSONEncode({
114
148
  success = false,
@@ -121,6 +155,11 @@ local function connect(): ()
121
155
 
122
156
  ws:Send(HttpService:JSONEncode({
123
157
  type = "results",
158
+ -- Echo the protocol version. A stale plugin that ignored
159
+ -- the request-side version would also omit the echo, so
160
+ -- the CLI's schema validation rejects it as malformed
161
+ -- rather than treating it as a valid v1 success.
162
+ protocolVersion = PROTOCOL_VERSION,
124
163
  request_id = message.request_id,
125
164
  jestOutput = result.jestOutput or HttpService:JSONEncode({
126
165
  success = false,
@@ -59,7 +59,123 @@ if not requireOk then
59
59
  return
60
60
  end
61
61
 
62
- local runOk, entriesOrErr = pcall(Runner.runProjects, script, configs)
62
+ -- Runtime injection of jest.config ModuleScripts per config. Studio +
63
+ -- multi-project does NOT write jest.config.luau into the user's source
64
+ -- tree — the configs travel via the WebSocket payload and we materialize
65
+ -- them into DataModel ModuleScripts just before Jest discovery runs. The
66
+ -- session-UUID `_G` key keeps each Run Mode invocation isolated against
67
+ -- re-entrancy (the assert below fails loud if state from a prior run
68
+ -- somehow persisted).
69
+ local SESSION_KEY = "__JEST_ROBLOX_RT_" .. HttpService:GenerateGUID(false):gsub("-", "")
70
+ assert(_G[SESSION_KEY] == nil, "Session key collision — re-entrant run detected")
71
+ _G[SESSION_KEY] = { configs = {} }
72
+
73
+ local function findInstance(path: string): Instance
74
+ local parts = string.split(path, "/")
75
+ local current: any = game:FindService(parts[1])
76
+ assert(current, "service not found: " .. parts[1])
77
+ for i = 2, #parts do
78
+ current = current:FindFirstChild(parts[i])
79
+ assert(current, "missing segment '" .. parts[i] .. "' in " .. path)
80
+ end
81
+ return current
82
+ end
83
+
84
+ -- Per-config record of injected ModuleScripts so cleanup after each
85
+ -- config's Jest invocation can destroy exactly the stubs that config
86
+ -- created. Per-config (rather than upfront-all) avoids the nested-project
87
+ -- contamination path where configs at e.g. `ReplicatedStorage` and
88
+ -- `ReplicatedStorage/Foo` would both have stubs in DataModel
89
+ -- simultaneously and Jest's parent-traversal could resolve the wrong one.
90
+ local injectedPerConfig: { [number]: { Instance } } = {}
91
+
92
+ -- The CLI sends a filtered `runtimeStubMounts[i]` list per config,
93
+ -- excluding mounts that already have a user-authored `jest.config.luau`
94
+ -- on disk (synced naturally by Rojo). We must iterate this filtered list,
95
+ -- not `cfg.projects`, or we'd inject over the user's canonical config.
96
+ -- Defaults to empty when the field is missing (defensive against
97
+ -- malformed payload from a divergent plugin).
98
+ local function injectStubsForConfig(cfg, configIndex: number, injectionPaths: { string })
99
+ local sessionTable = _G[SESSION_KEY]
100
+ sessionTable.configs[configIndex] = cfg
101
+ -- Register the slot BEFORE parenting any stub. If a later mount
102
+ -- throws (findInstance miss, structural collision), the partially-
103
+ -- parented stubs are already tracked so cleanupStubsForConfig /
104
+ -- cleanupAllStubs can find and destroy them. Without this, an early
105
+ -- stub would linger in DataModel after cleanup.
106
+ local injected: { Instance } = {}
107
+ injectedPerConfig[configIndex] = injected
108
+
109
+ for _, projectPath in injectionPaths do
110
+ local leaf = findInstance(projectPath)
111
+ assert(
112
+ leaf:FindFirstChild("jest.config") == nil,
113
+ "Structural collision: jest.config already exists under "
114
+ .. projectPath
115
+ .. ". This may be a user-authored config not declared as a string entry, "
116
+ .. "a rojo tree mapping, or a model-file mount."
117
+ )
118
+ local stub = Instance.new("ModuleScript")
119
+ stub.Name = "jest.config"
120
+ stub.Source = string.format(
121
+ 'return _G["%s"].configs[%d]',
122
+ SESSION_KEY,
123
+ configIndex
124
+ )
125
+ stub.Parent = leaf
126
+ -- Record immediately after parenting so a subsequent mount's
127
+ -- failure does not strand this one.
128
+ table.insert(injected, stub)
129
+ end
130
+ end
131
+
132
+ local function cleanupStubsForConfig(configIndex: number)
133
+ local injected = injectedPerConfig[configIndex]
134
+ if injected ~= nil then
135
+ for _, inst in injected do
136
+ pcall(function() inst:Destroy() end)
137
+ end
138
+ injectedPerConfig[configIndex] = nil
139
+ end
140
+
141
+ local sessionTable = _G[SESSION_KEY]
142
+ if sessionTable ~= nil then
143
+ sessionTable.configs[configIndex] = nil
144
+ end
145
+ end
146
+
147
+ local function cleanupAllStubs()
148
+ for index in injectedPerConfig do
149
+ cleanupStubsForConfig(index)
150
+ end
151
+ _G[SESSION_KEY] = nil
152
+ end
153
+
154
+ -- Per-config injection target lists, parallel to `configs`. The CLI sends a
155
+ -- pre-filtered list excluding mount paths whose source tree already has a
156
+ -- user-authored `jest.config.luau` synced by Rojo. Empty entry (or missing
157
+ -- field) means: skip injection for this config. The hooks below let
158
+ -- `Runner.runProjects` call our inject/cleanup just-in-time per config, so
159
+ -- nested-project mounts (e.g. `ReplicatedStorage` and `ReplicatedStorage/Foo`)
160
+ -- never have stubs in DataModel simultaneously — which would let Jest's
161
+ -- parent-traversal config lookup resolve the wrong project's config.
162
+ local runtimeStubMounts: { { string } } = testArgs.runtimeStubMounts
163
+ or table.create(#configs, {})
164
+
165
+ local hooks = {
166
+ beforeConfig = function(cfg, index)
167
+ local paths = runtimeStubMounts[index] or {}
168
+ injectStubsForConfig(cfg, index, paths)
169
+ end,
170
+ afterConfig = function(_cfg, index)
171
+ cleanupStubsForConfig(index)
172
+ end,
173
+ }
174
+
175
+ local runOk, entriesOrErr = pcall(Runner.runProjects, script, configs, hooks)
176
+ -- Defensive: also clear `_G` even if every afterConfig hook already cleaned
177
+ -- per-config. Belt-and-suspenders against a hook-call that yielded early.
178
+ cleanupAllStubs()
63
179
  if not runOk then
64
180
  endWithError("Runner.runProjects threw: " .. tostring(entriesOrErr))
65
181
  return