dominus-cli 0.1.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.
@@ -0,0 +1,141 @@
1
+ --[[
2
+ TestRunner: interface with StudioTestService for automated testing
3
+ https://create.roblox.com/docs/reference/engine/classes/StudioTestService
4
+
5
+ StudioTestService allows plugins to:
6
+ - ExecuteRunModeAsync(args) → start Run mode, yield until EndTest
7
+ - ExecutePlayModeAsync(args) → start Play/Solo mode, yield until EndTest
8
+ - GetTestArgs() → retrieve args from inside the running session
9
+ - EndTest(value) → end the session and return a value
10
+ ]]
11
+
12
+ local TestRunner = {}
13
+
14
+ local StudioTestService = game:GetService("StudioTestService")
15
+
16
+ function TestRunner.executeRunMode(args)
17
+ local startTime = os.clock()
18
+ local ok, result = pcall(function()
19
+ return StudioTestService:ExecuteRunModeAsync(args)
20
+ end)
21
+ local duration = os.clock() - startTime
22
+
23
+ if not ok then
24
+ return {
25
+ success = false,
26
+ error = tostring(result),
27
+ duration = duration,
28
+ }
29
+ end
30
+
31
+ return {
32
+ success = true,
33
+ result = result,
34
+ duration = duration,
35
+ }
36
+ end
37
+
38
+ function TestRunner.executePlayMode(args)
39
+ local startTime = os.clock()
40
+ local ok, result = pcall(function()
41
+ return StudioTestService:ExecutePlayModeAsync(args)
42
+ end)
43
+ local duration = os.clock() - startTime
44
+
45
+ if not ok then
46
+ return {
47
+ success = false,
48
+ error = tostring(result),
49
+ duration = duration,
50
+ }
51
+ end
52
+
53
+ return {
54
+ success = true,
55
+ result = result,
56
+ duration = duration,
57
+ }
58
+ end
59
+
60
+ function TestRunner.endTest(value)
61
+ local ok, err = pcall(function()
62
+ StudioTestService:EndTest(value)
63
+ end)
64
+
65
+ if not ok then
66
+ return { success = false, error = tostring(err) }
67
+ end
68
+
69
+ return { success = true }
70
+ end
71
+
72
+ function TestRunner.run(testName)
73
+ local results = {}
74
+
75
+ local ok, err = pcall(function()
76
+ local TestService = game:GetService("TestService")
77
+
78
+ if testName then
79
+ local testScript = nil
80
+ for _, desc in game:GetDescendants() do
81
+ if desc:IsA("ModuleScript") and desc.Name == testName then
82
+ testScript = desc
83
+ break
84
+ end
85
+ end
86
+
87
+ if not testScript then
88
+ table.insert(results, {
89
+ name = testName,
90
+ passed = false,
91
+ duration = 0,
92
+ error = "Test not found: " .. testName,
93
+ })
94
+ return
95
+ end
96
+
97
+ local startTime = os.clock()
98
+ local testOk, testErr = pcall(function()
99
+ require(testScript)
100
+ end)
101
+ local duration = os.clock() - startTime
102
+
103
+ table.insert(results, {
104
+ name = testName,
105
+ passed = testOk,
106
+ duration = duration,
107
+ error = not testOk and tostring(testErr) or nil,
108
+ })
109
+ else
110
+ for _, desc in game:GetDescendants() do
111
+ if desc:IsA("ModuleScript") then
112
+ local name = desc.Name
113
+ if string.match(name, "%.test$") or string.match(name, "%.spec$")
114
+ or string.match(name, "Test$") or string.match(name, "Spec$") then
115
+
116
+ local startTime = os.clock()
117
+ local testOk, testErr = pcall(function()
118
+ require(desc)
119
+ end)
120
+ local duration = os.clock() - startTime
121
+
122
+ table.insert(results, {
123
+ name = name,
124
+ passed = testOk,
125
+ duration = duration,
126
+ error = not testOk and tostring(testErr) or nil,
127
+ })
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end)
133
+
134
+ if not ok then
135
+ return { results = {{ name = "TestRunner", passed = false, duration = 0, error = tostring(err) }} }
136
+ end
137
+
138
+ return { results = results }
139
+ end
140
+
141
+ return TestRunner
@@ -0,0 +1,247 @@
1
+ --[[
2
+ UIBuilder: create entire UI trees from a declarative JSON spec in one call.
3
+ Handles all type coercion natively in Luau — no round trips needed.
4
+ ]]
5
+
6
+ local Explorer = require(script.Parent.Explorer)
7
+
8
+ local UIBuilder = {}
9
+
10
+ local function resolveColor(value)
11
+ if type(value) == "string" then
12
+ if value:sub(1, 1) == "#" then
13
+ return Color3.fromHex(value)
14
+ end
15
+ local r, g, b = value:match("rgb%s*%((%d+)%D+(%d+)%D+(%d+)%)")
16
+ if r then return Color3.fromRGB(tonumber(r), tonumber(g), tonumber(b)) end
17
+ local ok, bc = pcall(BrickColor.new, value)
18
+ if ok then return bc.Color end
19
+ end
20
+ if type(value) == "table" then
21
+ if value[1] then
22
+ if value[1] > 1 or value[2] > 1 or value[3] > 1 then
23
+ return Color3.fromRGB(value[1], value[2], value[3])
24
+ end
25
+ return Color3.new(value[1], value[2], value[3])
26
+ end
27
+ if value.R or value.r then
28
+ return Color3.new(value.R or value.r or 0, value.G or value.g or 0, value.B or value.b or 0)
29
+ end
30
+ end
31
+ return Color3.new(1, 1, 1)
32
+ end
33
+
34
+ local function resolveUDim2(value)
35
+ if type(value) == "table" then
36
+ if value.XScale or value.xScale then
37
+ return UDim2.new(
38
+ value.XScale or value.xScale or 0, value.XOffset or value.xOffset or 0,
39
+ value.YScale or value.yScale or 0, value.YOffset or value.yOffset or 0
40
+ )
41
+ end
42
+ if value.X and type(value.X) == "table" then
43
+ return UDim2.new(
44
+ value.X.Scale or value.X[1] or 0, value.X.Offset or value.X[2] or 0,
45
+ value.Y and (value.Y.Scale or value.Y[1]) or 0, value.Y and (value.Y.Offset or value.Y[2]) or 0
46
+ )
47
+ end
48
+ if value[1] ~= nil then
49
+ return UDim2.new(value[1] or 0, value[2] or 0, value[3] or 0, value[4] or 0)
50
+ end
51
+ end
52
+ if type(value) == "string" then
53
+ local a, b, c, d = value:match("([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)")
54
+ if a then return UDim2.new(tonumber(a), tonumber(b), tonumber(c), tonumber(d)) end
55
+ end
56
+ return UDim2.new(0, 0, 0, 0)
57
+ end
58
+
59
+ local function resolveUDim(value)
60
+ if type(value) == "table" then
61
+ return UDim.new(value.Scale or value[1] or 0, value.Offset or value[2] or 0)
62
+ end
63
+ if type(value) == "number" then
64
+ return UDim.new(0, value)
65
+ end
66
+ return UDim.new(0, 0)
67
+ end
68
+
69
+ local function resolveVector2(value)
70
+ if type(value) == "table" then
71
+ return Vector2.new(value.X or value.x or value[1] or 0, value.Y or value.y or value[2] or 0)
72
+ end
73
+ return Vector2.new(0, 0)
74
+ end
75
+
76
+ local function resolveEnum(instance, key, value)
77
+ if type(value) ~= "string" then return value end
78
+ local curOk, curVal = pcall(function() return instance[key] end)
79
+ if curOk and typeof(curVal) == "EnumItem" then
80
+ local enumType = tostring(curVal.EnumType)
81
+ local ok, enumVal = pcall(function() return Enum[enumType][value] end)
82
+ if ok then return enumVal end
83
+ end
84
+ return value
85
+ end
86
+
87
+ local function resolveFont(value)
88
+ if type(value) == "string" then
89
+ local ok, font = pcall(function() return Enum.Font[value] end)
90
+ if ok then return font end
91
+ end
92
+ return value
93
+ end
94
+
95
+ local UDIM2_PROPS = {
96
+ Size = true, Position = true, AnchorPoint_UDim2 = true,
97
+ }
98
+
99
+ local COLOR_PROPS = {
100
+ BackgroundColor3 = true, BorderColor3 = true, TextColor3 = true,
101
+ ImageColor3 = true, PlaceholderColor3 = true, TextStrokeColor3 = true,
102
+ ScrollBarImageColor3 = true, Color = true,
103
+ }
104
+
105
+ local VECTOR2_PROPS = {
106
+ AnchorPoint = true, CanvasSize_v2 = true,
107
+ }
108
+
109
+ local function applyProperty(instance, key, value)
110
+ -- Special handling by property name
111
+ if key == "Size" or key == "Position" then
112
+ if instance:IsA("GuiObject") or instance:IsA("ScreenGui") then
113
+ instance[key] = resolveUDim2(value)
114
+ return
115
+ end
116
+ end
117
+
118
+ if key == "AnchorPoint" then
119
+ if instance:IsA("GuiObject") then
120
+ instance[key] = resolveVector2(value)
121
+ return
122
+ end
123
+ end
124
+
125
+ if key == "CanvasSize" and instance:IsA("ScrollingFrame") then
126
+ instance[key] = resolveUDim2(value)
127
+ return
128
+ end
129
+
130
+ if key == "CornerRadius" then
131
+ instance[key] = resolveUDim(value)
132
+ return
133
+ end
134
+
135
+ if COLOR_PROPS[key] then
136
+ instance[key] = resolveColor(value)
137
+ return
138
+ end
139
+
140
+ if key == "Font" and instance:IsA("TextLabel") or instance:IsA("TextButton") or instance:IsA("TextBox") then
141
+ instance[key] = resolveFont(value)
142
+ return
143
+ end
144
+
145
+ if key == "FontFace" then
146
+ if type(value) == "table" then
147
+ local family = value.Family or "rbxasset://fonts/families/SourceSansPro.json"
148
+ local weight = Enum.FontWeight.Regular
149
+ local style = Enum.FontStyle.Normal
150
+ pcall(function() weight = Enum.FontWeight[value.Weight or "Regular"] end)
151
+ pcall(function() style = Enum.FontStyle[value.Style or "Normal"] end)
152
+ instance.FontFace = Font.new(family, weight, style)
153
+ end
154
+ return
155
+ end
156
+
157
+ -- Enum properties (string values)
158
+ if type(value) == "string" then
159
+ local resolved = resolveEnum(instance, key, value)
160
+ local ok, err = pcall(function() instance[key] = resolved end)
161
+ if not ok then
162
+ -- Maybe it's a direct value
163
+ pcall(function() instance[key] = value end)
164
+ end
165
+ return
166
+ end
167
+
168
+ -- Direct assignment for numbers, booleans, etc.
169
+ pcall(function() instance[key] = value end)
170
+ end
171
+
172
+ local function buildNode(spec, parent)
173
+ local className = spec.ClassName or spec.className or "Frame"
174
+ local name = spec.Name or spec.name or className
175
+
176
+ local instance = Instance.new(className)
177
+ instance.Name = name
178
+
179
+ -- Apply all properties
180
+ for key, value in spec do
181
+ if key ~= "ClassName" and key ~= "className" and key ~= "Name" and key ~= "name" and key ~= "Children" and key ~= "children" then
182
+ applyProperty(instance, key, value)
183
+ end
184
+ end
185
+
186
+ -- Set parent after properties to avoid unnecessary layout computations
187
+ instance.Parent = parent
188
+
189
+ -- Build children recursively
190
+ local children = spec.Children or spec.children
191
+ if children then
192
+ for _, childSpec in children do
193
+ buildNode(childSpec, instance)
194
+ end
195
+ end
196
+
197
+ return instance
198
+ end
199
+
200
+ function UIBuilder.build(spec)
201
+ local parentPath = spec.parent or "StarterGui"
202
+ local parent = Explorer.resolveInstance(parentPath)
203
+ if not parent then
204
+ -- Try as a service
205
+ local ok
206
+ ok, parent = pcall(function() return game:GetService(parentPath) end)
207
+ if not ok then
208
+ return { success = false, error = "Parent not found: " .. parentPath }
209
+ end
210
+ end
211
+
212
+ -- Clean up existing instance with same name
213
+ local existingName = spec.tree and (spec.tree.Name or spec.tree.name) or nil
214
+ if existingName then
215
+ local existing = parent:FindFirstChild(existingName)
216
+ if existing then
217
+ existing:Destroy()
218
+ end
219
+ end
220
+
221
+ local tree = spec.tree
222
+ if not tree then
223
+ return { success = false, error = "No 'tree' provided in spec" }
224
+ end
225
+
226
+ local ok, result = pcall(function()
227
+ return buildNode(tree, parent)
228
+ end)
229
+
230
+ if not ok then
231
+ return { success = false, error = "Build failed: " .. tostring(result) }
232
+ end
233
+
234
+ local path = Explorer.getPath(result)
235
+ local count = 0
236
+ local function countDescendants(inst)
237
+ count = count + 1
238
+ for _, child in inst:GetChildren() do
239
+ countDescendants(child)
240
+ end
241
+ end
242
+ countDescendants(result)
243
+
244
+ return { success = true, path = path, instanceCount = count }
245
+ end
246
+
247
+ return UIBuilder
@@ -0,0 +1,85 @@
1
+ --[[
2
+ Watcher: monitors LogService output and forwards to CLI
3
+ ]]
4
+
5
+ local LogService = game:GetService("LogService")
6
+
7
+ local Watcher = {}
8
+
9
+ local outputBuffer = {}
10
+ local MAX_BUFFER = 500
11
+ local connection = nil
12
+ local outputCallback = nil
13
+
14
+ local function classifyLevel(messageType)
15
+ if messageType == Enum.MessageType.MessageOutput then
16
+ return "info"
17
+ elseif messageType == Enum.MessageType.MessageWarning then
18
+ return "warn"
19
+ elseif messageType == Enum.MessageType.MessageError then
20
+ return "error"
21
+ end
22
+ return "info"
23
+ end
24
+
25
+ function Watcher.start(callback)
26
+ outputCallback = callback
27
+
28
+ if connection then
29
+ connection:Disconnect()
30
+ end
31
+
32
+ connection = LogService.MessageOut:Connect(function(message, messageType)
33
+ -- Skip Dominus's own messages
34
+ if string.find(message, "[Dominus]", 1, true) then
35
+ return
36
+ end
37
+
38
+ local entry = {
39
+ message = message,
40
+ level = classifyLevel(messageType),
41
+ timestamp = os.clock(),
42
+ }
43
+
44
+ -- Buffer for get_output requests
45
+ table.insert(outputBuffer, entry)
46
+ if #outputBuffer > MAX_BUFFER then
47
+ table.remove(outputBuffer, 1)
48
+ end
49
+
50
+ -- Forward to CLI
51
+ if outputCallback then
52
+ outputCallback(entry)
53
+ end
54
+ end)
55
+ end
56
+
57
+ function Watcher.stop()
58
+ if connection then
59
+ connection:Disconnect()
60
+ connection = nil
61
+ end
62
+ outputCallback = nil
63
+ end
64
+
65
+ function Watcher.getRecentOutput(limit, levelFilter)
66
+ limit = limit or 50
67
+ local result = {}
68
+
69
+ for i = math.max(1, #outputBuffer - limit + 1), #outputBuffer do
70
+ local entry = outputBuffer[i]
71
+ if entry then
72
+ if not levelFilter or entry.level == levelFilter then
73
+ table.insert(result, entry)
74
+ end
75
+ end
76
+ end
77
+
78
+ return { entries = result }
79
+ end
80
+
81
+ function Watcher.clearBuffer()
82
+ outputBuffer = {}
83
+ end
84
+
85
+ return Watcher
@@ -0,0 +1,81 @@
1
+ --[[
2
+ WebSocket client wrapper using HttpService:CreateWebStreamClient
3
+ Uses the new WebSocket API available in Studio plugins (Oct 2025+)
4
+ ]]
5
+
6
+ local HttpService = game:GetService("HttpService")
7
+
8
+ local WsClient = {}
9
+ WsClient.__index = WsClient
10
+
11
+ local currentConnection = nil
12
+ local messageCallback = nil
13
+ local closeCallback = nil
14
+ local errorCallback = nil
15
+
16
+ function WsClient.connect(url, callbacks)
17
+ local ok, wsOrErr = pcall(function()
18
+ return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, {
19
+ Url = url,
20
+ })
21
+ end)
22
+
23
+ if not ok then
24
+ warn("[Dominus WS] Failed to create WebSocket:", wsOrErr)
25
+ if callbacks.onError then
26
+ callbacks.onError(tostring(wsOrErr))
27
+ end
28
+ return false
29
+ end
30
+
31
+ local wsClient = wsOrErr
32
+ currentConnection = wsClient
33
+
34
+ -- Handle incoming messages
35
+ wsClient.MessageReceived:Connect(function(message)
36
+ if callbacks.onMessage then
37
+ callbacks.onMessage(message)
38
+ end
39
+ end)
40
+
41
+ -- Handle disconnection
42
+ wsClient.Closed:Connect(function()
43
+ currentConnection = nil
44
+ if callbacks.onClose then
45
+ callbacks.onClose()
46
+ end
47
+ end)
48
+
49
+ -- Notify connected
50
+ if callbacks.onOpen then
51
+ callbacks.onOpen()
52
+ end
53
+
54
+ return true
55
+ end
56
+
57
+ function WsClient.send(message)
58
+ if currentConnection then
59
+ local ok, err = pcall(function()
60
+ currentConnection:Send(message)
61
+ end)
62
+ if not ok then
63
+ warn("[Dominus WS] Send error:", err)
64
+ end
65
+ end
66
+ end
67
+
68
+ function WsClient.disconnect()
69
+ if currentConnection then
70
+ pcall(function()
71
+ currentConnection:Close()
72
+ end)
73
+ currentConnection = nil
74
+ end
75
+ end
76
+
77
+ function WsClient.isConnected()
78
+ return currentConnection ~= nil
79
+ end
80
+
81
+ return WsClient