@isentinel/jest-roblox 0.0.2 → 0.0.4

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.
@@ -0,0 +1,172 @@
1
+ --!strict
2
+ local HttpService = game:GetService("HttpService")
3
+ local LogService = game:GetService("LogService")
4
+
5
+ local InstanceResolver = require(script.Parent:FindFirstChild('instance-resolver'))
6
+ local SnapshotPatch = require(script.Parent:FindFirstChild('snapshot-patch'))
7
+
8
+ type Config = {
9
+ jestPath: string?,
10
+ projects: { string }?,
11
+ setupFiles: { string }?,
12
+ setupFilesAfterEnv: { string }?,
13
+ _timing: boolean?,
14
+ }
15
+
16
+ local function fail(err: string)
17
+ return {
18
+ success = false,
19
+ err = err,
20
+ }
21
+ end
22
+
23
+ local module = {}
24
+
25
+ function module.run(callingScript: LuaSourceContainer, config: Config): (string, string)
26
+ local t0 = os.clock()
27
+ local timingEnabled = config._timing
28
+
29
+ local t_findJest0 = os.clock()
30
+ local findSuccess, findValue = pcall(InstanceResolver.getJest, config)
31
+ local t_findJest = os.clock()
32
+
33
+ if not findSuccess then
34
+ local logSuccess, logHistory = pcall(function()
35
+ return HttpService:JSONEncode(LogService:GetLogHistory())
36
+ end)
37
+
38
+ return HttpService:JSONEncode(fail(findValue :: any)),
39
+ if logSuccess then logHistory else "[]"
40
+ end
41
+
42
+ LogService:ClearOutput()
43
+
44
+ local snapshotWrites: { [string]: string } = {}
45
+
46
+ local t_patchSnapshot0 = os.clock()
47
+ local patchState = SnapshotPatch.patch(findValue, snapshotWrites)
48
+ local t_patchSnapshot = os.clock()
49
+
50
+ local t_requireJest0 = os.clock()
51
+ local Jest = (require :: any)(findValue)
52
+ local t_requireJest = os.clock()
53
+
54
+ local function runTests()
55
+ local t_resolveProjects0 = os.clock()
56
+ local projects = {}
57
+
58
+ assert(
59
+ config.projects and #config.projects > 0,
60
+ "No projects configured. Set 'projects' in jest.config.ts or pass --projects."
61
+ )
62
+
63
+ for _, projectPath in config.projects do
64
+ table.insert(projects, InstanceResolver.findInstance(projectPath))
65
+ end
66
+
67
+ config.projects = {}
68
+ local t_resolveProjects = os.clock()
69
+
70
+ local t_resolveSetupFiles0 = os.clock()
71
+ if config.setupFiles and #config.setupFiles > 0 then
72
+ local resolved = {}
73
+
74
+ for _, setupPath in config.setupFiles do
75
+ table.insert(resolved, InstanceResolver.findInstance(setupPath))
76
+ end
77
+
78
+ config.setupFiles = resolved :: any
79
+ end
80
+ if config.setupFilesAfterEnv and #config.setupFilesAfterEnv > 0 then
81
+ local resolved = {}
82
+
83
+ for _, setupPath in config.setupFilesAfterEnv do
84
+ table.insert(resolved, InstanceResolver.findInstance(setupPath))
85
+ end
86
+
87
+ config.setupFilesAfterEnv = resolved :: any
88
+ end
89
+ local t_resolveSetupFiles = os.clock()
90
+
91
+ config._timing = nil :: any
92
+
93
+ local t_jestRunCLI0 = os.clock()
94
+ local jestResult = Jest.runCLI(callingScript, config, projects):expect()
95
+ local t_jestRunCLI = os.clock()
96
+
97
+ local result: { [string]: any } = {
98
+ success = true,
99
+ value = jestResult,
100
+ }
101
+
102
+ if timingEnabled then
103
+ result._timing = {
104
+ findJest = t_findJest - t_findJest0,
105
+ patchSnapshot = t_patchSnapshot - t_patchSnapshot0,
106
+ requireJest = t_requireJest - t_requireJest0,
107
+ resolveProjects = t_resolveProjects - t_resolveProjects0,
108
+ resolveSetupFiles = t_resolveSetupFiles - t_resolveSetupFiles0,
109
+ jestRunCLI = t_jestRunCLI - t_jestRunCLI0,
110
+ total = os.clock() - t0,
111
+ }
112
+ end
113
+
114
+ if next(snapshotWrites) then
115
+ result._snapshotWrites = snapshotWrites
116
+ end
117
+
118
+ return result
119
+ end
120
+
121
+ local jestDone = false
122
+ local runSuccess = false
123
+ local runValue: any = nil
124
+
125
+ task.spawn(function()
126
+ local ok, val = pcall(runTests)
127
+ jestDone = true
128
+ runSuccess = ok
129
+ runValue = val
130
+ end)
131
+
132
+ local infiniteYieldMessage: string? = nil
133
+ local watchdogConnection = LogService.MessageOut:Connect(
134
+ function(message: string, messageType: Enum.MessageType)
135
+ if
136
+ messageType == Enum.MessageType.MessageWarning
137
+ and string.find(message, "Infinite yield possible")
138
+ and not infiniteYieldMessage
139
+ then
140
+ infiniteYieldMessage = message
141
+ end
142
+ end
143
+ )
144
+
145
+ while not jestDone and not infiniteYieldMessage do
146
+ task.wait(0.1)
147
+ end
148
+
149
+ watchdogConnection:Disconnect()
150
+
151
+ if not jestDone and infiniteYieldMessage then
152
+ runSuccess = false
153
+ runValue = "Infinite yield detected, aborting tests: " .. infiniteYieldMessage
154
+ end
155
+
156
+ SnapshotPatch.unpatch(patchState)
157
+
158
+ local jestResult
159
+ if not runSuccess then
160
+ jestResult = HttpService:JSONEncode(fail(runValue :: any))
161
+ else
162
+ jestResult = HttpService:JSONEncode(runValue)
163
+ end
164
+
165
+ local logSuccess, logHistory = pcall(function()
166
+ return HttpService:JSONEncode(LogService:GetLogHistory())
167
+ end)
168
+
169
+ return jestResult, if logSuccess then logHistory else "[]"
170
+ end
171
+
172
+ return module
@@ -0,0 +1,99 @@
1
+ --!strict
2
+ local CoreScriptSyncService = require(script.Parent:FindFirstChild('mock'):FindFirstChild('CoreScriptSyncService'))
3
+ local InstanceResolver = require(script.Parent:FindFirstChild('instance-resolver'))
4
+
5
+ local module = {}
6
+
7
+ function module.createMockGetDataModelService(snapshotWrites: { [string]: string })
8
+ local FileSystemService = require(script.Parent:FindFirstChild('mock'):FindFirstChild('FileSystemService'))(snapshotWrites)
9
+
10
+ return function(service: string): any
11
+ if service == "FileSystemService" then
12
+ return FileSystemService
13
+ elseif service == "CoreScriptSyncService" then
14
+ return CoreScriptSyncService
15
+ end
16
+
17
+ local success, result = pcall(function()
18
+ local service_ = game:GetService(service)
19
+ local _ = service_.Name
20
+ return service_
21
+ end)
22
+
23
+ return if success then result else nil
24
+ end
25
+ end
26
+
27
+ export type PatchState = {
28
+ robloxSharedExports: any,
29
+ originalGetDataModelService: any,
30
+ Runtime: any,
31
+ originalRequireInternalModule: any,
32
+ }
33
+
34
+ function module.patch(jestModule: ModuleScript, snapshotWrites: { [string]: string }): PatchState?
35
+ local mockGetDataModelService = module.createMockGetDataModelService(snapshotWrites)
36
+
37
+ local robloxSharedInstance = InstanceResolver.findRobloxShared(jestModule)
38
+ if not robloxSharedInstance then
39
+ warn("Could not find RobloxShared; snapshot support unavailable")
40
+ return nil
41
+ end
42
+
43
+ local robloxSharedExports = (require :: any)(robloxSharedInstance)
44
+ local originalGetDataModelService = robloxSharedExports.getDataModelService
45
+ robloxSharedExports.getDataModelService = mockGetDataModelService
46
+
47
+ local getDataModelServiceChild = robloxSharedInstance:FindFirstChild("getDataModelService")
48
+
49
+ local jestRuntimeModule =
50
+ InstanceResolver.findSiblingPackage(jestModule, "JestRuntime", "jest-runtime")
51
+ if not jestRuntimeModule then
52
+ warn("Could not find JestRuntime; snapshot interception unavailable")
53
+ return {
54
+ robloxSharedExports = robloxSharedExports,
55
+ originalGetDataModelService = originalGetDataModelService,
56
+ Runtime = nil,
57
+ originalRequireInternalModule = nil,
58
+ }
59
+ end
60
+
61
+ local Runtime = (require :: any)(jestRuntimeModule)
62
+ local originalRequireInternalModule = Runtime.requireInternalModule
63
+
64
+ Runtime.requireInternalModule = function(self: any, from: any, to: any, ...): any
65
+ local target = if to ~= nil then to else from
66
+ if
67
+ getDataModelServiceChild
68
+ and typeof(target) == "Instance"
69
+ and target == getDataModelServiceChild
70
+ then
71
+ return mockGetDataModelService
72
+ end
73
+
74
+ return originalRequireInternalModule(self, from, to, ...)
75
+ end
76
+
77
+ return {
78
+ robloxSharedExports = robloxSharedExports,
79
+ originalGetDataModelService = originalGetDataModelService,
80
+ Runtime = Runtime,
81
+ originalRequireInternalModule = originalRequireInternalModule,
82
+ }
83
+ end
84
+
85
+ function module.unpatch(state: PatchState?)
86
+ if not state then
87
+ return
88
+ end
89
+
90
+ if state.robloxSharedExports and state.originalGetDataModelService then
91
+ state.robloxSharedExports.getDataModelService = state.originalGetDataModelService
92
+ end
93
+
94
+ if state.Runtime and state.originalRequireInternalModule then
95
+ state.Runtime.requireInternalModule = state.originalRequireInternalModule
96
+ end
97
+ end
98
+
99
+ return module
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "JestRobloxRunner",
3
3
  "tree": {
4
- "$path": "src"
4
+ "$path": "src",
5
+ "shared": {
6
+ "$path": "out/shared"
7
+ }
5
8
  }
6
9
  }
@@ -27,21 +27,8 @@ if not loadStringEnabled then
27
27
  return
28
28
  end
29
29
 
30
- local TestRunner = require(script.Parent["test-runner"])
31
- local jestOutput, gameOutput = TestRunner.runTestsAync(script, testArgs.config or {})
32
-
33
- -- Inject snapshot writes from shared global table into jestOutput
34
- local snapshotWrites = _G.__snapshotWrites
35
- if snapshotWrites and next(snapshotWrites) then
36
- local ok, decoded = pcall(function()
37
- return HttpService:JSONDecode(jestOutput)
38
- end)
39
-
40
- if ok and type(decoded) == "table" then
41
- decoded._snapshotWrites = snapshotWrites
42
- jestOutput = HttpService:JSONEncode(decoded)
43
- end
44
- end
30
+ local Runner = require(script.Parent.shared.runner)
31
+ local jestOutput, gameOutput = Runner.run(script, testArgs.config or {})
45
32
 
46
33
  StudioTestService:EndTest({
47
34
  jestOutput = jestOutput,
@@ -1 +0,0 @@
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"]}]}
@@ -1,19 +0,0 @@
1
- -- Mock CoreScriptSyncService: builds instance-to-path mapping
2
- -- Uses custom getInstancePath that stops at game (excludes DataModel name)
3
- local function getInstancePath(instance: Instance): string
4
- local parts = {}
5
- local curr: Instance? = instance
6
- while curr and curr ~= game do
7
- table.insert(parts, 1, (curr :: Instance).Name)
8
- curr = (curr :: Instance).Parent
9
- end
10
- return table.concat(parts, "/")
11
- end
12
-
13
- local CoreScriptSyncService = {}
14
-
15
- function CoreScriptSyncService:GetScriptFilePath(script_: Instance): string
16
- return getInstancePath(script_)
17
- end
18
-
19
- return CoreScriptSyncService
@@ -1,27 +0,0 @@
1
- -- Mock FileSystemService: collects snapshot writes in-memory Uses
2
- -- _G.__snapshotWrites so all module load contexts (standard Luau + Jest
3
- -- runtime) share the same table. The test-runner creates this before patching.
4
- local snapshotWrites: { [string]: string } = _G.__snapshotWrites or {}
5
-
6
- local FileSystemService = {}
7
-
8
- function FileSystemService:WriteFile(path: string, contents: string)
9
- -- Rewrite .snap.lua -> .snap.luau for Rojo compatibility
10
- snapshotWrites[string.gsub(path, "%.snap%.lua$", ".snap.luau")] = contents
11
- end
12
-
13
- function FileSystemService:CreateDirectories(_path: string) end
14
-
15
- function FileSystemService:Exists(path: string): boolean
16
- return snapshotWrites[string.gsub(path, "%.snap%.lua$", ".snap.luau")] ~= nil
17
- end
18
-
19
- function FileSystemService:Remove(path: string)
20
- snapshotWrites[string.gsub(path, "%.snap%.lua$", ".snap.luau")] = nil
21
- end
22
-
23
- function FileSystemService:IsRegularFile(path: string): boolean
24
- return snapshotWrites[string.gsub(path, "%.snap%.lua$", ".snap.luau")] ~= nil
25
- end
26
-
27
- return FileSystemService
@@ -1,19 +0,0 @@
1
- -- Patched getDataModelService: returns mock services for snapshot support
2
- local CoreScriptSyncService = require(script.Parent.CoreScriptSyncService)
3
- local FileSystemService = require(script.Parent.FileSystemService)
4
-
5
- return function(service: string)
6
- if service == "FileSystemService" then
7
- return FileSystemService
8
- elseif service == "CoreScriptSyncService" then
9
- return CoreScriptSyncService
10
- end
11
-
12
- local success, result = pcall(function()
13
- local svc = game:GetService(service)
14
- local _ = svc.Name
15
- return svc
16
- end)
17
-
18
- return success and result or nil
19
- end
@@ -1,217 +0,0 @@
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
- -- Find RobloxShared relative to Jest module
45
- local function findRobloxShared(jestModule: ModuleScript): Instance?
46
- local parent = jestModule.Parent
47
- if parent then
48
- local found = parent:FindFirstChild("RobloxShared")
49
- or parent:FindFirstChild("jest-roblox-shared")
50
- if found then
51
- return found
52
- end
53
-
54
- if parent.Parent then
55
- found = parent.Parent:FindFirstChild("RobloxShared")
56
- or parent.Parent:FindFirstChild("jest-roblox-shared")
57
- if found then
58
- return found
59
- end
60
- end
61
- end
62
-
63
- return game:FindFirstChild("RobloxShared", true)
64
- end
65
-
66
- -- Replace a child ModuleScript inside RobloxShared with a patched clone
67
- local function replaceChild(parent: Instance, name: string, patchSource: ModuleScript)
68
- local original = parent:FindFirstChild(name)
69
- if original then
70
- original:Destroy()
71
- end
72
-
73
- local clone = patchSource:Clone()
74
- clone.Name = name
75
- clone.Parent = parent
76
- end
77
-
78
- -- Patch RobloxShared by replacing ModuleScript children
79
- local function patchRobloxShared(jestModule: ModuleScript, patchFolder: Instance): boolean
80
- local robloxShared = findRobloxShared(jestModule)
81
- if not robloxShared then
82
- warn("[jest-roblox-cli] Could not find RobloxShared; snapshot support disabled")
83
- return false
84
- end
85
-
86
- local patchGetDataModelService = patchFolder:FindFirstChild("getDataModelService")
87
- local patchFileSystemService = patchFolder:FindFirstChild("FileSystemService")
88
- local patchCoreScriptSyncService = patchFolder:FindFirstChild("CoreScriptSyncService")
89
-
90
- if not (patchGetDataModelService and patchFileSystemService and patchCoreScriptSyncService) then
91
- warn("[jest-roblox-cli] Missing patch modules; snapshot support disabled")
92
- return false
93
- end
94
-
95
- -- Replace children inside RobloxShared
96
- replaceChild(robloxShared, "FileSystemService", patchFileSystemService :: ModuleScript)
97
- replaceChild(robloxShared, "CoreScriptSyncService", patchCoreScriptSyncService :: ModuleScript)
98
- replaceChild(robloxShared, "getDataModelService", patchGetDataModelService :: ModuleScript)
99
-
100
- warn("[jest-roblox-cli] Patched RobloxShared at:", robloxShared:GetFullName())
101
- return true
102
- end
103
-
104
- function module.runTestsAync(
105
- callingScript: LuaSourceContainer,
106
- config: {
107
- [string]: any,
108
- }
109
- ): (string, string)
110
- warn("Running tests with config:", config)
111
- local t0 = os.clock()
112
- local timingEnabled = config._timing
113
-
114
- local t_findJest0 = os.clock()
115
- local findSuccess, findValue = pcall(function(): ModuleScript
116
- return module.getJest(config)
117
- end)
118
- local t_findJest = os.clock()
119
-
120
- if not findSuccess then
121
- local logSuccess, logHistory = pcall(function(): string
122
- return HttpService:JSONEncode(LogService:GetLogHistory())
123
- end)
124
-
125
- return HttpService:JSONEncode({ success = false, err = findValue }),
126
- if logSuccess then logHistory else "[]"
127
- end
128
-
129
- -- Snapshot support: create shared table BEFORE patching so all module
130
- -- load contexts (standard Luau + Jest runtime) reference the same table
131
- _G.__snapshotWrites = {}
132
-
133
- local t_patchSnapshot0 = os.clock()
134
- local patchFolder = (callingScript :: any).Parent.patch
135
- if patchFolder then
136
- patchRobloxShared(findValue, patchFolder)
137
- end
138
- local t_patchSnapshot = os.clock()
139
-
140
- local t_requireJest0 = os.clock()
141
- local Jest = (require :: any)(findValue)
142
- local t_requireJest = os.clock()
143
-
144
- local function run(): string
145
- LogService:ClearOutput()
146
-
147
- local t_resolveProjects0 = os.clock()
148
- local projects = {}
149
-
150
- assert(
151
- config.projects and #config.projects > 0,
152
- "No projects configured. Set 'projects' in jest.config.ts or pass --projects."
153
- )
154
-
155
- for _, projectPath in config.projects do
156
- table.insert(projects, module.findInstance(projectPath))
157
- end
158
-
159
- config.projects = {}
160
- local t_resolveProjects = os.clock()
161
-
162
- local t_resolveSetupFiles0 = os.clock()
163
- if config.setupFiles and #config.setupFiles > 0 then
164
- local resolved = {}
165
-
166
- for _, setupPath in config.setupFiles do
167
- table.insert(resolved, module.findInstance(setupPath))
168
- end
169
-
170
- config.setupFiles = resolved
171
- end
172
- local t_resolveSetupFiles = os.clock()
173
-
174
- config._timing = nil
175
-
176
- local t_jestRunCLI0 = os.clock()
177
- local jestResult = Jest.runCLI(callingScript, config, projects):expect()
178
- local t_jestRunCLI = os.clock()
179
-
180
- local result: { [string]: any } = {
181
- success = true,
182
- value = jestResult,
183
- }
184
-
185
- if timingEnabled then
186
- result._timing = {
187
- configDecode = 0,
188
- findJest = t_findJest - t_findJest0,
189
- patchSnapshot = t_patchSnapshot - t_patchSnapshot0,
190
- requireJest = t_requireJest - t_requireJest0,
191
- resolveProjects = t_resolveProjects - t_resolveProjects0,
192
- resolveSetupFiles = t_resolveSetupFiles - t_resolveSetupFiles0,
193
- jestRunCLI = t_jestRunCLI - t_jestRunCLI0,
194
- total = os.clock() - t0,
195
- }
196
- end
197
-
198
- return HttpService:JSONEncode(result)
199
- end
200
-
201
- local runSuccess, runValue = pcall(run)
202
-
203
- local jestResult: string
204
- if not runSuccess then
205
- jestResult = HttpService:JSONEncode({ success = false, err = runValue })
206
- else
207
- jestResult = runValue
208
- end
209
-
210
- local logSuccess, logHistory = pcall(function(): string
211
- return HttpService:JSONEncode(LogService:GetLogHistory())
212
- end)
213
-
214
- return jestResult, if logSuccess then logHistory else "[]"
215
- end
216
-
217
- return module