@isentinel/jest-roblox 0.0.1

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 ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@isentinel/jest-roblox",
3
+ "version": "0.0.1",
4
+ "description": "Jest-compatible CLI for running roblox-ts tests via Roblox Open Cloud",
5
+ "keywords": [
6
+ "jest",
7
+ "roblox",
8
+ "rbxts",
9
+ "testing",
10
+ "cli"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Christopher Buss <christopher.buss@pm.me> (https://github.com/christopher-buss)",
14
+ "sideEffects": false,
15
+ "type": "module",
16
+ "exports": {
17
+ ".": "./dist/index.mjs",
18
+ "./cli": "./dist/cli.mjs",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "types": "./dist/index.d.mts",
22
+ "bin": {
23
+ "jest-roblox": "./bin/jest-roblox.js"
24
+ },
25
+ "files": [
26
+ "bin",
27
+ "dist",
28
+ "plugin",
29
+ "!dist/**/*.map",
30
+ "!dist/**/*.tsbuildinfo"
31
+ ],
32
+ "dependencies": {
33
+ "arktype": "2.1.29",
34
+ "highlight.js": "11.11.1",
35
+ "jiti": "2.6.1",
36
+ "tinyrainbow": "3.0.3",
37
+ "ws": "8.18.0"
38
+ },
39
+ "devDependencies": {
40
+ "@isentinel/eslint-config": "4.7.6",
41
+ "@rbxts/jest": "3.13.3-ts.1",
42
+ "@rbxts/types": "1.0.896",
43
+ "@types/node": "24.10.4",
44
+ "@types/ws": "8.5.13",
45
+ "better-typescript-lib": "2.12.0",
46
+ "bumpp": "10.4.1",
47
+ "publint": "0.3.15",
48
+ "tsdown": "0.20.1",
49
+ "type-fest": "5.2.0",
50
+ "vitest": "4.0.16"
51
+ },
52
+ "engines": {
53
+ "node": ">=20.0.0"
54
+ },
55
+ "nx": {
56
+ "tags": [
57
+ "scope:tooling",
58
+ "type:cli"
59
+ ],
60
+ "projectType": "application",
61
+ "targets": {
62
+ "lint": {
63
+ "command": "eslint",
64
+ "hasTypeAwareRules": true
65
+ },
66
+ "test": {
67
+ "command": "vitest run",
68
+ "options": {
69
+ "cwd": "tools/jest-roblox-cli"
70
+ }
71
+ }
72
+ }
73
+ },
74
+ "scripts": {
75
+ "build": "tsdown",
76
+ "build:plugin": "rojo build plugin/plugin.project.json -o plugin/JestRobloxRunner.rbxm",
77
+ "release": "bumpp",
78
+ "watch": "tsdown --watch"
79
+ }
80
+ }
Binary file
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "JestRobloxRunner",
3
+ "tree": {
4
+ "$path": "src"
5
+ }
6
+ }
@@ -0,0 +1 @@
1
+ {"name":"JestRobloxRunner","className":"Script","filePaths":["src\\init.server.luau","plugin.project.json"],"children":[{"name":"test-in-run-mode","className":"Script","filePaths":["src\\test-in-run-mode.server.luau"]},{"name":"test-runner","className":"ModuleScript","filePaths":["src\\test-runner.luau"]}]}
@@ -0,0 +1,193 @@
1
+ --!strict
2
+ local HttpService = game:GetService("HttpService")
3
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
4
+ local RunService = game:GetService("RunService")
5
+ local StudioTestService = game:GetService("StudioTestService")
6
+
7
+ if RunService:IsRunning() then
8
+ return
9
+ end
10
+
11
+ local WEBSOCKET_URL = "ws://localhost:3001"
12
+ local RECONNECT_DELAY = 5
13
+
14
+ local IsDebug = ReplicatedStorage:GetAttribute("JEST_ROBLOX_DEBUG") == true
15
+ ReplicatedStorage:GetAttributeChangedSignal("JEST_ROBLOX_DEBUG"):Connect(function(): ()
16
+ IsDebug = ReplicatedStorage:GetAttribute("JEST_ROBLOX_DEBUG") == true
17
+ end)
18
+
19
+ local toolbar = plugin:CreateToolbar("Jest-Roblox")
20
+ local startButton =
21
+ toolbar:CreateButton("Jest Roblox", "Connect to jest-roblox CLI", "rbxassetid://4458901886")
22
+ local stopButton =
23
+ toolbar:CreateButton("Stop", "Disconnect from jest-roblox CLI", "rbxassetid://4458901886")
24
+ startButton.Enabled = false
25
+
26
+ local autoReconnect = true
27
+ local ws: WebStreamClient? = nil
28
+ local connections: { RBXScriptConnection } = {}
29
+
30
+ local _print = print
31
+ local _warn = warn
32
+
33
+ local function print(...: any): ()
34
+ if IsDebug then
35
+ _print("[jest-roblox]", ...)
36
+ end
37
+ end
38
+
39
+ local function warn(...: any): ()
40
+ if IsDebug then
41
+ _warn("[jest-roblox]", ...)
42
+ end
43
+ end
44
+
45
+ local function clearConnections(): ()
46
+ for _, connection in connections do
47
+ connection:Disconnect()
48
+ end
49
+
50
+ table.clear(connections)
51
+ end
52
+
53
+ local function disconnect(): ()
54
+ clearConnections()
55
+
56
+ if ws then
57
+ pcall(function(): ()
58
+ assert(ws, "Luau")
59
+ ws:Close()
60
+ end)
61
+ ws = nil
62
+ end
63
+ end
64
+
65
+ local function connect(): ()
66
+ clearConnections()
67
+
68
+ print("[jest-roblox] Connecting to " .. WEBSOCKET_URL .. "...")
69
+
70
+ local success, socket = pcall(function(): WebStreamClient
71
+ return HttpService:CreateWebStreamClient(
72
+ Enum.WebStreamClientType.WebSocket,
73
+ { Url = WEBSOCKET_URL }
74
+ )
75
+ end)
76
+
77
+ if not success then
78
+ warn("[jest-roblox] Failed to connect: " .. tostring(socket))
79
+ return
80
+ end
81
+
82
+ ws = socket
83
+ assert(ws, "Luau")
84
+
85
+ table.insert(
86
+ connections,
87
+ ws.MessageReceived:Connect(function(rawMessage: string): ()
88
+ assert(ws, "Luau")
89
+
90
+ local ok, message = pcall(function(): any
91
+ return HttpService:JSONDecode(rawMessage)
92
+ end)
93
+ if not ok then
94
+ warn("[jest-roblox] Failed to decode message:", rawMessage)
95
+ return
96
+ end
97
+
98
+ if message.action == "run_tests" then
99
+ print("[jest-roblox] Running tests via StudioTestService...")
100
+
101
+ local runOk, result = pcall(function(): any
102
+ return StudioTestService:ExecuteRunModeAsync({
103
+ test = true,
104
+ config = message.config,
105
+ })
106
+ end)
107
+
108
+ if not runOk then
109
+ warn("[jest-roblox] ExecuteRunModeAsync failed: " .. tostring(result))
110
+ ws:Send(HttpService:JSONEncode({
111
+ type = "results",
112
+ request_id = message.request_id,
113
+ jestOutput = HttpService:JSONEncode({
114
+ success = false,
115
+ err = tostring(result),
116
+ }),
117
+ gameOutput = "[]",
118
+ }))
119
+ return
120
+ end
121
+
122
+ ws:Send(HttpService:JSONEncode({
123
+ type = "results",
124
+ request_id = message.request_id,
125
+ jestOutput = result.jestOutput or HttpService:JSONEncode({
126
+ success = false,
127
+ err = "No jestOutput in run-mode result",
128
+ }),
129
+ gameOutput = result.gameOutput or "[]",
130
+ }))
131
+
132
+ print("[jest-roblox] Results sent")
133
+ end
134
+ end)
135
+ )
136
+
137
+ local opened = ws.Opened:Once(function(): ()
138
+ print("[jest-roblox] Connected to CLI")
139
+ end)
140
+ table.insert(connections, opened)
141
+
142
+ local err = ws.Error:Once(function(_statusCode: number, errorMessage: string): ()
143
+ warn("[jest-roblox] Connection error: " .. errorMessage)
144
+ end)
145
+ table.insert(connections, err)
146
+
147
+ local closed = ws.Closed:Once(function(): ()
148
+ ws = nil
149
+ if autoReconnect then
150
+ print("[jest-roblox] Disconnected, reconnecting in " .. RECONNECT_DELAY .. "s...")
151
+ local thread = task.delay(RECONNECT_DELAY, function(): ()
152
+ connect()
153
+ end)
154
+ table.insert(connections :: any, {
155
+ Disconnect = function(): ()
156
+ if coroutine.running() then
157
+ local wasCancelled = pcall(function()
158
+ task.cancel(thread)
159
+ end)
160
+
161
+ if not wasCancelled then
162
+ task.defer(function()
163
+ task.cancel(thread)
164
+ end)
165
+ end
166
+ end
167
+ end,
168
+ })
169
+ end
170
+ end)
171
+ table.insert(connections, closed)
172
+ end
173
+
174
+ startButton.Click:Connect(function(): ()
175
+ startButton.Enabled = false
176
+ stopButton.Enabled = true
177
+ autoReconnect = true
178
+ connect()
179
+ end)
180
+
181
+ stopButton.Click:Connect(function(): ()
182
+ stopButton.Enabled = false
183
+ startButton.Enabled = true
184
+ autoReconnect = false
185
+ disconnect()
186
+ end)
187
+
188
+ plugin.Unloading:Connect(function(): ()
189
+ autoReconnect = false
190
+ disconnect()
191
+ end)
192
+
193
+ task.defer(connect)
@@ -0,0 +1,36 @@
1
+ --!strict
2
+ local HttpService = game:GetService("HttpService")
3
+ local RunService = game:GetService("RunService")
4
+ local StudioTestService = game:GetService("StudioTestService")
5
+
6
+ if not RunService:IsRunning() then
7
+ return
8
+ end
9
+
10
+ local testArgs = StudioTestService:GetTestArgs()
11
+ if not testArgs or not testArgs.test then
12
+ return
13
+ end
14
+
15
+ local loadStringEnabled = pcall(function()
16
+ loadstring("return true")
17
+ end)
18
+
19
+ if not loadStringEnabled then
20
+ StudioTestService:EndTest({
21
+ jestOutput = HttpService:JSONEncode({
22
+ success = false,
23
+ err = "LoadString must be enabled in ServerScriptService to run tests",
24
+ }),
25
+ gameOutput = "[]",
26
+ })
27
+ return
28
+ end
29
+
30
+ local TestRunner = require(script.Parent["test-runner"])
31
+ local jestOutput, gameOutput = TestRunner.runTestsAync(script, testArgs.config or {})
32
+
33
+ StudioTestService:EndTest({
34
+ jestOutput = jestOutput,
35
+ gameOutput = gameOutput,
36
+ })
@@ -0,0 +1,145 @@
1
+ --!strict
2
+ local HttpService = game:GetService("HttpService")
3
+ local LogService = game:GetService("LogService")
4
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
5
+
6
+ local module = {}
7
+
8
+ function module.findInstance(path: string): Instance
9
+ local parts = string.split(path, "/")
10
+
11
+ local result, current: Instance? = pcall(function(): Instance
12
+ return game:FindService(parts[1])
13
+ end)
14
+ assert(result, `Failed to find service {parts[1]}: {current}`)
15
+
16
+ for i = 2, #parts do
17
+ assert(current, `Failed to find instance at {parts[i]}`)
18
+ current = current:FindFirstChild(parts[i])
19
+ end
20
+
21
+ assert(current, `Failed to find instance at path {path}: {current}`)
22
+
23
+ return current
24
+ end
25
+
26
+ function module.getJest(config: { jestPath: string? }): ModuleScript
27
+ local jestPath = config.jestPath
28
+ if jestPath then
29
+ local instance = module.findInstance(jestPath)
30
+ assert(instance, `Failed to find Jest instance at path {jestPath}`)
31
+ assert(instance:IsA("ModuleScript"), `Instance at path {jestPath} is not a ModuleScript`)
32
+ return instance
33
+ end
34
+
35
+ local jestInstance = ReplicatedStorage:FindFirstChild("Jest", true)
36
+ assert(jestInstance, "Failed to find Jest instance in ReplicatedStorage")
37
+ assert(
38
+ jestInstance:IsA("ModuleScript"),
39
+ "Jest instance in ReplicatedStorage is not a ModuleScript"
40
+ )
41
+ return jestInstance
42
+ end
43
+
44
+ function module.runTestsAync(
45
+ callingScript: LuaSourceContainer,
46
+ config: {
47
+ [string]: any,
48
+ }
49
+ ): (string, string)
50
+ warn("Running tests with config:", config)
51
+ local t0 = os.clock()
52
+ local timingEnabled = config._timing
53
+
54
+ local t_findJest0 = os.clock()
55
+ local findSuccess, findValue = pcall(function(): ModuleScript
56
+ return module.getJest(config)
57
+ end)
58
+ local t_findJest = os.clock()
59
+
60
+ if not findSuccess then
61
+ local logSuccess, logHistory = pcall(function(): string
62
+ return HttpService:JSONEncode(LogService:GetLogHistory())
63
+ end)
64
+
65
+ return HttpService:JSONEncode({ success = false, err = findValue }),
66
+ if logSuccess then logHistory else "[]"
67
+ end
68
+
69
+ local t_requireJest0 = os.clock()
70
+ local Jest = (require :: any)(findValue)
71
+ local t_requireJest = os.clock()
72
+
73
+ local function run(): string
74
+ LogService:ClearOutput()
75
+
76
+ local t_resolveProjects0 = os.clock()
77
+ local projects = {}
78
+
79
+ assert(
80
+ config.projects and #config.projects > 0,
81
+ "No projects configured. Set 'projects' in jest.config.ts or pass --projects."
82
+ )
83
+
84
+ for _, projectPath in config.projects do
85
+ table.insert(projects, module.findInstance(projectPath))
86
+ end
87
+
88
+ config.projects = {}
89
+ local t_resolveProjects = os.clock()
90
+
91
+ local t_resolveSetupFiles0 = os.clock()
92
+ if config.setupFiles and #config.setupFiles > 0 then
93
+ local resolved = {}
94
+
95
+ for _, setupPath in config.setupFiles do
96
+ table.insert(resolved, module.findInstance(setupPath))
97
+ end
98
+
99
+ config.setupFiles = resolved
100
+ end
101
+ local t_resolveSetupFiles = os.clock()
102
+
103
+ config._timing = nil
104
+
105
+ local t_jestRunCLI0 = os.clock()
106
+ local jestResult = Jest.runCLI(callingScript, config, projects):expect()
107
+ local t_jestRunCLI = os.clock()
108
+
109
+ local result: { [string]: any } = {
110
+ success = true,
111
+ value = jestResult,
112
+ }
113
+
114
+ if timingEnabled then
115
+ result._timing = {
116
+ configDecode = 0,
117
+ findJest = t_findJest - t_findJest0,
118
+ requireJest = t_requireJest - t_requireJest0,
119
+ resolveProjects = t_resolveProjects - t_resolveProjects0,
120
+ resolveSetupFiles = t_resolveSetupFiles - t_resolveSetupFiles0,
121
+ jestRunCLI = t_jestRunCLI - t_jestRunCLI0,
122
+ total = os.clock() - t0,
123
+ }
124
+ end
125
+
126
+ return HttpService:JSONEncode(result)
127
+ end
128
+
129
+ local runSuccess, runValue = pcall(run)
130
+
131
+ local jestResult: string
132
+ if not runSuccess then
133
+ jestResult = HttpService:JSONEncode({ success = false, err = runValue })
134
+ else
135
+ jestResult = runValue
136
+ end
137
+
138
+ local logSuccess, logHistory = pcall(function(): string
139
+ return HttpService:JSONEncode(LogService:GetLogHistory())
140
+ end)
141
+
142
+ return jestResult, if logSuccess then logHistory else "[]"
143
+ end
144
+
145
+ return module