diff --git a/plugin/UploadDetails.json b/plugin/UploadDetails.json index 2396598f..2441794c 100644 --- a/plugin/UploadDetails.json +++ b/plugin/UploadDetails.json @@ -1,7 +1,7 @@ { "assetId": 13916111004, - "name": "Rojo", - "description": "The plugin portion of Rojo, a tool to enable professional tooling for Roblox developers.", + "name": "Prism", + "description": "The Prism plugin, a tool to enable professional tooling for Roblox developers.", "creatorId": 32644114, "creatorType": "Group" -} \ No newline at end of file +} diff --git a/plugin/assets/prism/icon.png b/plugin/assets/prism/icon.png new file mode 100644 index 00000000..50eee4b8 Binary files /dev/null and b/plugin/assets/prism/icon.png differ diff --git a/plugin/assets/prism/logo.png b/plugin/assets/prism/logo.png new file mode 100644 index 00000000..7995a594 Binary files /dev/null and b/plugin/assets/prism/logo.png differ diff --git a/plugin/assets/prism/warning.png b/plugin/assets/prism/warning.png new file mode 100644 index 00000000..ae79969f Binary files /dev/null and b/plugin/assets/prism/warning.png differ diff --git a/plugin/http/Error.lua b/plugin/http/Error.lua index f9e46b38..98ea041f 100644 --- a/plugin/http/Error.lua +++ b/plugin/http/Error.lua @@ -3,11 +3,11 @@ Error.__index = Error Error.Kind = { HttpNotEnabled = { - message = "Rojo requires HTTP access, which is not enabled.\n" + message = "Prism requires HTTP access, which is not enabled.\n" .. "Check your game settings, located in the 'Home' tab of Studio.", }, ConnectFailed = { - message = "Couldn't connect to the Rojo server.\n" + message = "Couldn't connect to the Prism server.\n" .. "Make sure the server is running — use 'rojo serve' to run it!", }, Timeout = { diff --git a/plugin/log/init.lua b/plugin/log/init.lua index 882be997..51242975 100644 --- a/plugin/log/init.lua +++ b/plugin/log/init.lua @@ -16,10 +16,10 @@ local function addTags(tag, message) return tag .. message:gsub("\n", "\n" .. tag) end -local TRACE_TAG = (" "):rep(15) .. "[Rojo-Trace] " -local INFO_TAG = (" "):rep(15) .. "[Rojo-Info] " -local DEBUG_TAG = (" "):rep(15) .. "[Rojo-Debug] " -local WARN_TAG = "[Rojo-Warn] " +local TRACE_TAG = (" "):rep(15) .. "[Prism-Trace] " +local INFO_TAG = (" "):rep(15) .. "[Prism-Info] " +local DEBUG_TAG = (" "):rep(15) .. "[Prism-Debug] " +local WARN_TAG = "[Prism-Warn] " local Log = {} diff --git a/plugin/src/ApiContext.lua b/plugin/src/ApiContext.lua index 2ce6ea14..0b064463 100644 --- a/plugin/src/ApiContext.lua +++ b/plugin/src/ApiContext.lua @@ -14,6 +14,8 @@ local validateApiSocketPacket = Types.ifEnabled(Types.ApiSocketPacket) local validateApiSerialize = Types.ifEnabled(Types.ApiSerializeResponse) local validateApiRefPatch = Types.ifEnabled(Types.ApiRefPatchResponse) +local EXEC_COMPLETION_BODY_LIMIT_BYTES = 256 * 1024 + local function rejectFailedRequests(response) if response.code >= 400 then local message = string.format("HTTP %s:\n%s", tostring(response.code), response.body) @@ -24,11 +26,37 @@ local function rejectFailedRequests(response) return response end +local function decodeExecResponse(response, validator, description) + local decodeOk, body = pcall(response.msgpack, response) + if not decodeOk then + return Promise.reject( + string.format("Prism exec protocol error: could not decode %s: %s", description, tostring(body)) + ) + end + + local valid, validationError = validator(body) + if not valid then + return Promise.reject( + string.format("Prism exec protocol error: malformed %s: %s", description, tostring(validationError)) + ) + end + + return body +end + +local function isCompletionConflict(errorValue) + -- The existing HTTP wrapper intentionally turns every non-2xx response + -- into Http.Error.Unknown and includes the status at the start of its + -- message. Keep this compatibility check here instead of creating another + -- HTTP connection path just for exec. + return tostring(errorValue):find("^Unknown HTTP error: 409:") ~= nil +end + local function rejectWrongProtocolVersion(infoResponseBody) if infoResponseBody.protocolVersion ~= Config.protocolVersion then local message = ( - "Found a Rojo dev server, but it's using a different protocol version, and is incompatible." - .. "\nMake sure you have matching versions of both the Rojo plugin and server!" + "Found a Prism dev server, but it's using a different protocol version, and is incompatible." + .. "\nMake sure you have matching versions of both the Prism plugin and server!" .. "\n\nYour client is version %s, with protocol version %s. It expects server version %s." .. "\nYour server is version %s, with protocol version %s." .. "\n\nGo to https://github.com/rojo-rbx/rojo for more details." @@ -57,7 +85,7 @@ local function rejectWrongPlaceId(infoResponseBody) end local message = ( - "Found a Rojo server, but its project is set to only be used with a specific list of places." + "Found a Prism server, but its project is set to only be used with a specific list of places." .. "\nYour place ID is %u, but needs to be one of these:" .. "\n%s" .. "\n\nTo change this list, edit 'servePlaceIds' in your .project.json file." @@ -77,7 +105,7 @@ local function rejectWrongPlaceId(infoResponseBody) end local message = ( - "Found a Rojo server, but its project is set to not be used with a specific list of places." + "Found a Prism server, but its project is set to not be used with a specific list of places." .. "\nYour place ID is %u, but needs to not be one of these:" .. "\n%s" .. "\n\nTo change this list, edit 'blockedPlaceIds' in your .project.json file." @@ -328,4 +356,69 @@ function ApiContext:refPatch(ids: { string }) end) end +function ApiContext:claimNextExecJob() + local url = ("%s/api/exec/jobs/next"):format(self.__baseUrl) + + return Http.get(url):andThen(function(response) + if response.code == 204 then + return nil + end + + if response.code ~= 200 then + return Promise.reject(string.format("Unexpected exec claim response status %s", tostring(response.code))) + end + + return decodeExecResponse(response, Types.ApiExecClaimResponse, "claimed-job response") + end) +end + +function ApiContext:completeExecJob(jobId, payload) + local validJobId, jobIdError = Types.Uuid(jobId) + if not validJobId then + return Promise.reject(string.format("Cannot complete exec job: %s", tostring(jobIdError))) + end + + local encodeOk, body = pcall(Http.msgpackEncode, payload) + if not encodeOk then + return Promise.reject("Could not encode exec completion payload: " .. tostring(body)) + end + if #body > EXEC_COMPLETION_BODY_LIMIT_BYTES then + return Promise.reject( + string.format( + "Exec completion payload is %d bytes, exceeding the %d-byte server limit", + #body, + EXEC_COMPLETION_BODY_LIMIT_BYTES + ) + ) + end + + local url = ("%s/api/exec/jobs/%s/complete"):format(self.__baseUrl, jobId) + + return Http.post(url, body) + :andThen(function(response) + if response.code ~= 200 then + return Promise.reject( + string.format("Unexpected exec completion response status %s", tostring(response.code)) + ) + end + + local responseBody = decodeExecResponse(response, Types.ApiExecJobResponse, "completion response") + return Promise.resolve(responseBody):andThen(function(validatedBody) + return { + status = "accepted", + job = validatedBody, + } + end) + end) + :catch(function(errorValue) + if isCompletionConflict(errorValue) then + return { + status = "conflict", + } + end + + return Promise.reject(errorValue) + end) +end + return ApiContext diff --git a/plugin/src/App/Components/Header.lua b/plugin/src/App/Components/Header.lua index fa8c9173..d7ccb1f8 100644 --- a/plugin/src/App/Components/Header.lua +++ b/plugin/src/App/Components/Header.lua @@ -85,7 +85,7 @@ local function Header(props) ImageColor3 = theme.Header.LogoColor, ImageTransparency = props.transparency, - Size = UDim2.new(0, 60, 0, 27), + Size = UDim2.fromOffset(27, 27), LayoutOrder = 1, BackgroundTransparency = 1, diff --git a/plugin/src/App/Components/Notifications/FullscreenNotification.lua b/plugin/src/App/Components/Notifications/FullscreenNotification.lua index 47d0ae6f..ef0b12a2 100644 --- a/plugin/src/App/Components/Notifications/FullscreenNotification.lua +++ b/plugin/src/App/Components/Notifications/FullscreenNotification.lua @@ -116,7 +116,7 @@ function FullscreenNotification:render() Image = Assets.Images.Logo, ImageColor3 = theme.Header.LogoColor, BackgroundTransparency = 1, - Size = UDim2.fromOffset(60, 27), + Size = UDim2.fromOffset(27, 27), LayoutOrder = 1, }), Info = e("TextLabel", { diff --git a/plugin/src/App/StatusPages/Confirming.lua b/plugin/src/App/StatusPages/Confirming.lua index 951df0f5..8b4538bd 100644 --- a/plugin/src/App/StatusPages/Confirming.lua +++ b/plugin/src/App/StatusPages/Confirming.lua @@ -98,7 +98,7 @@ function ConfirmingPage:render() onClick = self.props.onReject, }, { Tip = e(Tooltip.Trigger, { - text = "Push Studio changes to the Rojo server", + text = "Push Studio changes to the Prism server", }), }) else nil, @@ -111,7 +111,7 @@ function ConfirmingPage:render() onClick = self.props.onAccept, }, { Tip = e(Tooltip.Trigger, { - text = "Pull Rojo server changes to Studio", + text = "Pull Prism server changes to Studio", }), }), diff --git a/plugin/src/App/StatusPages/Connected.lua b/plugin/src/App/StatusPages/Connected.lua index 2a489366..8fc9108b 100644 --- a/plugin/src/App/StatusPages/Connected.lua +++ b/plugin/src/App/StatusPages/Connected.lua @@ -468,7 +468,7 @@ function ConnectedPage:render() onClick = self.props.onDisconnect, }, { Tip = e(Tooltip.Trigger, { - text = "Disconnect from the Rojo sync server", + text = "Disconnect from the Prism sync server", }), }), diff --git a/plugin/src/App/StatusPages/NotConnected.lua b/plugin/src/App/StatusPages/NotConnected.lua index aabf7640..2d0ae466 100644 --- a/plugin/src/App/StatusPages/NotConnected.lua +++ b/plugin/src/App/StatusPages/NotConnected.lua @@ -132,7 +132,7 @@ function NotConnectedPage:render() onClick = self.props.onConnect, }, { Tip = e(Tooltip.Trigger, { - text = "Connect to a Rojo sync server", + text = "Connect to a Prism sync server", }), }), diff --git a/plugin/src/App/StatusPages/Settings/init.lua b/plugin/src/App/StatusPages/Settings/init.lua index b93eba3e..4abc5514 100644 --- a/plugin/src/App/StatusPages/Settings/init.lua +++ b/plugin/src/App/StatusPages/Settings/init.lua @@ -214,7 +214,7 @@ function SettingsPage:render() AutoConnectPlaytestServer = e(Setting, { id = "autoConnectPlaytestServer", name = "Auto Connect Playtest Server", - description = "Automatically connect game server to Rojo when playtesting while connected in Edit", + description = "Automatically connect game server to Prism when playtesting while connected in Edit", tag = "unstable", transparency = self.props.transparency, layoutOrder = layoutIncrement(), @@ -269,7 +269,7 @@ function SettingsPage:render() TimingLogsEnabled = e(Setting, { id = "timingLogsEnabled", name = "Timing Logs", - description = "Toggle logging timing of internal actions for benchmarking Rojo performance", + description = "Toggle logging timing of internal actions for benchmarking Prism performance", tag = "debug", transparency = self.props.transparency, layoutOrder = layoutIncrement(), diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index e6e4deee..40873df0 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -63,7 +63,7 @@ function App:init() self.notifId = 0 self.waypointConnection = ChangeHistoryService.OnUndo:Connect(function(action: string) - if not string.find(action, "^Rojo: Patch") then + if not string.find(action, "^Prism: Patch") then return end @@ -853,7 +853,9 @@ function App:endSession() end function App:render() - local pluginName = "Rojo " .. Version.display(Config.version) + -- Keep the legacy widget ID so existing Studio dock state is preserved. + local pluginId = "Rojo " .. Version.display(Config.version) + local pluginName = "Prism " .. Version.display(Config.version) local function createPageElement(appStatus, additionalProps) additionalProps = additionalProps or {} @@ -872,7 +874,7 @@ function App:render() e(Theme.StudioProvider, nil, { tooltip = e(Tooltip.Provider, nil, { gui = e(StudioPluginGui, { - id = pluginName, + id = pluginId, title = pluginName, active = self.state.guiEnabled, isEphemeral = false, @@ -993,10 +995,11 @@ function App:render() }), }), + -- Keep action IDs stable so existing Studio shortcuts continue to work. toggleAction = e(StudioPluginAction, { name = "RojoConnection", - title = "Rojo: Connect/Disconnect", - description = "Toggles the server for a Rojo sync session", + title = "Prism: Connect/Disconnect", + description = "Toggles the server for a Prism sync session", icon = Assets.Images.PluginButton, bindable = true, onTriggered = function() @@ -1012,8 +1015,8 @@ function App:render() connectAction = e(StudioPluginAction, { name = "RojoConnect", - title = "Rojo: Connect", - description = "Connects the server for a Rojo sync session", + title = "Prism: Connect", + description = "Connects the server for a Prism sync session", icon = Assets.Images.PluginButton, bindable = true, onTriggered = function() @@ -1025,8 +1028,8 @@ function App:render() disconnectAction = e(StudioPluginAction, { name = "RojoDisconnect", - title = "Rojo: Disconnect", - description = "Disconnects the server for a Rojo sync session", + title = "Prism: Disconnect", + description = "Disconnects the server for a Prism sync session", icon = Assets.Images.PluginButton, bindable = true, onTriggered = function() @@ -1040,8 +1043,8 @@ function App:render() name = pluginName, }, { button = e(StudioToggleButton, { - name = "Rojo", - tooltip = "Show or hide the Rojo panel", + name = "Rojo", -- Legacy toolbar button ID. + tooltip = "Show or hide the Prism panel", icon = self.state.toolbarIcon, active = self.state.guiEnabled, enabled = true, diff --git a/plugin/src/Assets.lua b/plugin/src/Assets.lua index c2c168ee..01891b72 100644 --- a/plugin/src/Assets.lua +++ b/plugin/src/Assets.lua @@ -16,8 +16,8 @@ local Assets = { }, }, Images = { - Logo = "rbxassetid://5990772764", - PluginButton = "rbxassetid://3405341609", + Logo = "rbxassetid://84145747248222", + PluginButton = "rbxassetid://84145747248222", PluginButtonConnected = "rbxassetid://9529783993", PluginButtonWarning = "rbxassetid://9529784530", Icons = { diff --git a/plugin/src/ChangeBatcher/createPatchSet.lua b/plugin/src/ChangeBatcher/createPatchSet.lua index 5502d0d6..547e230b 100644 --- a/plugin/src/ChangeBatcher/createPatchSet.lua +++ b/plugin/src/ChangeBatcher/createPatchSet.lua @@ -19,7 +19,7 @@ return function(instanceMap, propertyChanges) local instanceId = instanceMap.fromInstances[instance] if instanceId == nil then - Log.warn("Ignoring change for instance {:?} as it is unknown to Rojo", instance) + Log.warn("Ignoring change for instance {:?} as it is unknown to Prism", instance) continue end diff --git a/plugin/src/Exec.lua b/plugin/src/Exec.lua new file mode 100644 index 00000000..f74dbdd6 --- /dev/null +++ b/plugin/src/Exec.lua @@ -0,0 +1,796 @@ +local ChangeHistoryService = game:GetService("ChangeHistoryService") +local HttpService = game:GetService("HttpService") +local RunService = game:GetService("RunService") + +local Packages = script.Parent.Parent.Packages +local Http = require(Packages.Http) +local Log = require(Packages.Log) +local Promise = require(Packages.Promise) + +local POLL_INTERVAL_SECONDS = 0.25 +local COMPLETION_RETRY_DELAY_SECONDS = 0.25 +local COMPLETION_ATTEMPTS = 3 +local MAX_COMPLETION_BODY_BYTES = 256 * 1024 +local MAX_RESULT_DEPTH = 32 +local MAX_RESULT_ELEMENTS = 10_000 +local MAX_LOG_ENTRIES = 1_000 +local MAX_LOG_ENTRY_BYTES = 8 * 1024 +local MAX_ERROR_BYTES = 64 * 1024 +local MAX_TRACEBACK_BYTES = 64 * 1024 +local LOG_TRUNCATION_MESSAGE = "[Prism Exec] Logs truncated to fit protocol limits" + +-- The current Rust claim response does not expose its execution deadline. Its +-- store uses 30 seconds, so leave one second for the completion request. This +-- should become a wire value instead of a compatibility constant when the +-- protocol grows an execution-timeout field. +local EXECUTION_TIMEOUT_SECONDS = 29 + +local temporaryNameCounter = 0 + +local function safeTostring(value, stringify): string + local ok, rendered = pcall(stringify or tostring, value) + if ok and type(rendered) == "string" then + return rendered + end + + return "" +end + +local function truncateUtf8(value: string, limit: number, suffix: string?): string + if #value <= limit then + return value + end + + suffix = suffix or "" + local contentLimit = math.max(0, limit - #suffix) + local truncated = value:sub(1, contentLimit) + while #truncated > 0 and utf8.len(truncated) == nil do + truncated = truncated:sub(1, #truncated - 1) + end + + return truncated .. suffix +end + +local function captureError(errorValue, tracebackFunction) + local message = truncateUtf8(safeTostring(errorValue, tostring), MAX_ERROR_BYTES, "... [truncated]") + local tracebackOk, traceback = pcall(tracebackFunction, message, 2) + if not tracebackOk then + traceback = "" + else + traceback = safeTostring(traceback, tostring) + end + + return { + error = message, + traceback = truncateUtf8(traceback, MAX_TRACEBACK_BYTES, "... [truncated]"), + } +end + +local function makeProtectedOnce(callback) + local called = false + local packedResult = nil + + return function(...) + if not called then + called = true + packedResult = table.pack(pcall(callback, ...)) + end + + return table.unpack(packedResult, 1, packedResult.n) + end +end + +local function sanitizeScriptName(scriptName: string): string + local sanitized = scriptName:gsub("[^%w%._%-]", "_"):gsub("_+", "_") + sanitized = sanitized:sub(1, 48) + + if sanitized == "" or sanitized:find("%w") == nil then + return "script" + end + + return sanitized +end + +local function makeTemporaryName(scriptName: string, uniqueToken: string): string + temporaryNameCounter += 1 + + return string.format( + "__RojoExec_%s_%d_%s", + sanitizeScriptName(scriptName), + temporaryNameCounter, + sanitizeScriptName(uniqueToken) + ) +end + +local function buildWrapper(source: string): string + return "return function(rojoExec)\n" .. "\tlocal plugin = nil\n" .. source .. "\nend\n" +end + +local function encodeExecValue(value) + local seen = {} + local elementCount = 0 + + local function encode(current, depth, path) + elementCount += 1 + if elementCount > MAX_RESULT_ELEMENTS then + return nil, string.format("Exec result exceeds the %d-element limit", MAX_RESULT_ELEMENTS) + end + if depth > MAX_RESULT_DEPTH then + return nil, string.format("Exec result exceeds the maximum depth of %d at %s", MAX_RESULT_DEPTH, path) + end + + local valueType = type(current) + if valueType == "nil" then + return { kind = "nil" } + elseif valueType == "string" then + return { kind = "string", value = current } + elseif valueType == "number" then + if current ~= current or current == math.huge or current == -math.huge then + return nil, string.format("Exec result contains a non-finite number at %s", path) + end + return { kind = "number", value = current } + elseif valueType == "boolean" then + return { kind = "boolean", value = current } + elseif valueType ~= "table" then + return nil, string.format("Unsupported exec result type '%s' at %s", typeof(current), path) + end + + if seen[current] then + return nil, string.format("Exec result contains a cycle at %s", path) + end + seen[current] = true + + local numericKeys = 0 + local stringKeys = {} + local maximumIndex = 0 + for key in current do + local keyType = type(key) + if keyType == "number" and key >= 1 and key % 1 == 0 then + numericKeys += 1 + maximumIndex = math.max(maximumIndex, key) + elseif keyType == "string" then + table.insert(stringKeys, key) + else + seen[current] = nil + return nil, string.format("Exec result table at %s has unsupported key type '%s'", path, typeof(key)) + end + end + + if numericKeys > 0 and #stringKeys > 0 then + seen[current] = nil + return nil, string.format("Exec result table at %s mixes array and dictionary keys", path) + end + + local encoded + if numericKeys > 0 then + if maximumIndex ~= numericKeys then + seen[current] = nil + return nil, string.format("Exec result array at %s is sparse", path) + end + + local values = table.create(numericKeys) + for index = 1, numericKeys do + local child, childError = encode(current[index], depth + 1, string.format("%s[%d]", path, index)) + if child == nil then + seen[current] = nil + return nil, childError + end + values[index] = child + end + encoded = { kind = "array", value = values } + else + table.sort(stringKeys) + local entries = table.create(#stringKeys) + for index, key in stringKeys do + local child, childError = encode(current[key], depth + 1, string.format("%s.%s", path, key)) + if child == nil then + seen[current] = nil + return nil, childError + end + entries[index] = { + key = key, + value = child, + } + end + encoded = { kind = "table", value = entries } + end + + seen[current] = nil + return encoded + end + + return encode(value, 1, "result") +end + +local function makeLogCollector(stringify) + local entries = {} + local wasTruncated = false + + local collector = {} + + function collector:add(level, ...) + if #entries >= MAX_LOG_ENTRIES then + wasTruncated = true + return + end + + local renderedArguments = table.create(select("#", ...)) + for index = 1, select("#", ...) do + renderedArguments[index] = safeTostring(select(index, ...), stringify) + end + + local message = table.concat(renderedArguments, "\t") + if #message > MAX_LOG_ENTRY_BYTES then + message = truncateUtf8(message, MAX_LOG_ENTRY_BYTES, "... [truncated]") + wasTruncated = true + end + + table.insert(entries, { + level = level, + message = message, + }) + end + + function collector:finish() + local result = table.clone(entries) + if wasTruncated then + if #result >= MAX_LOG_ENTRIES then + table.remove(result) + end + table.insert(result, { + level = "warn", + message = LOG_TRUNCATION_MESSAGE, + }) + end + + return result + end + + return collector +end + +local function completionPayloadSize(payload, encodePayload) + local ok, body = pcall(encodePayload, payload) + if not ok then + return nil, safeTostring(body, tostring) + end + if type(body) ~= "string" then + return nil, "MessagePack encoder did not return a string" + end + + return #body +end + +local function boundCompletionPayload(payload, encodePayload) + if payload.error ~= nil then + payload.error = truncateUtf8(payload.error, MAX_ERROR_BYTES, "... [truncated]") + end + if payload.traceback ~= nil then + payload.traceback = truncateUtf8(payload.traceback, MAX_TRACEBACK_BYTES, "... [truncated]") + end + + local size, sizeError = completionPayloadSize(payload, encodePayload) + if size == nil then + return { + outcome = "runtimeFailure", + error = "Could not encode exec completion payload: " .. sizeError, + logs = {}, + } + end + if size <= MAX_COMPLETION_BODY_BYTES then + return payload + end + + local removedLogs = false + while #payload.logs > 0 and size > MAX_COMPLETION_BODY_BYTES do + table.remove(payload.logs) + removedLogs = true + size = completionPayloadSize(payload, encodePayload) + if size == nil then + break + end + end + + if removedLogs then + local marker = { + level = "warn", + message = LOG_TRUNCATION_MESSAGE, + } + table.insert(payload.logs, marker) + local markedSize = completionPayloadSize(payload, encodePayload) + if markedSize == nil or markedSize > MAX_COMPLETION_BODY_BYTES then + table.remove(payload.logs) + else + size = markedSize + end + end + + if size ~= nil and size <= MAX_COMPLETION_BODY_BYTES then + return payload + end + + if payload.outcome == "success" then + return boundCompletionPayload({ + outcome = "runtimeFailure", + error = string.format( + "Exec result exceeds the %d-byte completion payload limit", + MAX_COMPLETION_BODY_BYTES + ), + logs = payload.logs, + }, encodePayload) + end + + payload.traceback = nil + return payload +end + +local function failurePayload(outcome, errorMessage, traceback) + return { + outcome = outcome, + error = truncateUtf8(safeTostring(errorMessage, tostring), MAX_ERROR_BYTES, "... [truncated]"), + traceback = traceback, + logs = {}, + } +end + +local function makeExecutionController(dependencies) + local controller = { + temporaryModule = nil, + recording = nil, + logs = makeLogCollector(dependencies.stringify), + } + + controller.finishRecording = makeProtectedOnce(function() + if controller.recording ~= nil then + dependencies.finishRecording(controller.recording) + end + end) + + controller.cleanup = makeProtectedOnce(function() + if controller.temporaryModule ~= nil then + dependencies.cleanupTemporaryModule(controller.temporaryModule) + end + end) + + return controller +end + +local function finalizeController(controller, payload, dependencies) + local finishOk, finishError = controller.finishRecording() + if not finishOk then + local captured = captureError(finishError, dependencies.traceback) + local message = "Failed to finish the Prism exec undo recording: " .. captured.error + if payload.outcome == "success" then + payload = failurePayload("runtimeFailure", message, captured.traceback) + else + payload.error = payload.error .. "\n" .. message + payload.traceback = payload.traceback or captured.traceback + end + end + + local cleanupOk, cleanupError = controller.cleanup() + if not cleanupOk then + local captured = captureError(cleanupError, dependencies.traceback) + local message = "Failed to clean up the temporary Prism exec ModuleScript: " .. captured.error + if payload.outcome == "success" then + payload = failurePayload("runtimeFailure", message, captured.traceback) + else + payload.error = payload.error .. "\n" .. message + payload.traceback = payload.traceback or captured.traceback + end + end + + payload.logs = controller.logs:finish() + return boundCompletionPayload(payload, dependencies.encodePayload) +end + +local function runJob(job, dependencies, controller) + local payload = nil + local controllerOk, controllerError = xpcall(function() + if not dependencies.isEdit() then + payload = failurePayload("rejected", "Prism exec is available only in Studio edit mode") + return + end + + local temporaryName = makeTemporaryName(job.scriptName, dependencies.generateUniqueToken()) + local wrapper = buildWrapper(job.source) + controller.temporaryModule = dependencies.createTemporaryModule() + dependencies.configureTemporaryModule(controller.temporaryModule, temporaryName, wrapper) + + if not dependencies.isEdit() then + payload = failurePayload("rejected", "Studio left edit mode before the exec wrapper could be compiled") + return + end + + local loadOk, loadedValue = pcall(dependencies.requireTemporaryModule, controller.temporaryModule) + if not loadOk then + -- Studio reports the detailed parser diagnostic to Output, but require + -- only returns the generic module-load error. Keep the unique module + -- name in our error so the two messages can be correlated. + payload = failurePayload( + "compileFailure", + string.format("%s (temporary module: %s)", safeTostring(loadedValue, tostring), temporaryName) + ) + return + end + + if type(loadedValue) ~= "function" then + payload = failurePayload( + "compileFailure", + string.format("Exec wrapper returned '%s' instead of a callable function", type(loadedValue)) + ) + return + end + + controller.recording = dependencies.tryBeginRecording("Prism Exec: " .. job.scriptName) + if controller.recording == nil then + payload = failurePayload("rejected", "Could not begin the Prism exec undo recording") + return + end + + if not dependencies.isEdit() then + payload = failurePayload("rejected", "Studio left edit mode before the exec function could be invoked") + return + end + + local hasExplicitOutput = false + local explicitOutput = nil + local rojoExec = table.freeze({ + output = function(value) + hasExplicitOutput = true + explicitOutput = value + end, + print = function(...) + controller.logs:add("print", ...) + end, + warn = function(...) + controller.logs:add("warn", ...) + end, + }) + + local runtimeOk, runtimeValue = xpcall(function() + return loadedValue(rojoExec) + end, function(errorValue) + return captureError(errorValue, dependencies.traceback) + end) + + if not runtimeOk then + payload = failurePayload("runtimeFailure", runtimeValue.error, runtimeValue.traceback) + return + end + + local resultValue = if hasExplicitOutput then explicitOutput else runtimeValue + local encodedResult, resultError = encodeExecValue(resultValue) + if encodedResult == nil then + payload = failurePayload("runtimeFailure", resultError) + return + end + + payload = { + outcome = "success", + result = encodedResult, + logs = {}, + } + end, function(errorValue) + return captureError(errorValue, dependencies.traceback) + end) + + if not controllerOk then + payload = failurePayload("runtimeFailure", controllerError.error, controllerError.traceback) + elseif payload == nil then + payload = failurePayload("runtimeFailure", "Prism exec ended without producing a completion payload") + end + + return finalizeController(controller, payload, dependencies) +end + +local defaultExecutionDependencies = { + isEdit = function() + return RunService:IsEdit() + end, + generateUniqueToken = function() + return HttpService:GenerateGUID(false) + end, + createTemporaryModule = function() + return Instance.new("ModuleScript") + end, + configureTemporaryModule = function(temporaryModule, name, wrapper) + temporaryModule.Name = name + temporaryModule.Archivable = false + temporaryModule.Source = wrapper + temporaryModule.Parent = script + end, + requireTemporaryModule = function(temporaryModule) + return require(temporaryModule) + end, + tryBeginRecording = function(label) + return ChangeHistoryService:TryBeginRecording(label) + end, + finishRecording = function(recording) + ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit) + end, + cleanupTemporaryModule = function(temporaryModule) + temporaryModule:Destroy() + end, + traceback = debug.traceback, + stringify = tostring, + encodePayload = Http.msgpackEncode, + spawn = task.spawn, + cancel = task.cancel, + delay = Promise.delay, +} + +local function startExecution(job, dependencies, timeoutSeconds) + dependencies = dependencies or defaultExecutionDependencies + timeoutSeconds = timeoutSeconds or EXECUTION_TIMEOUT_SECONDS + + local controller = makeExecutionController(dependencies) + local workerThread = nil + local workerPromise = Promise.new(function(resolve, _reject, onCancel) + onCancel(function() + if workerThread ~= nil then + pcall(dependencies.cancel, workerThread) + end + controller.finishRecording() + controller.cleanup() + end) + + workerThread = dependencies.spawn(function() + resolve({ + kind = "completed", + payload = runJob(job, dependencies, controller), + }) + end) + end) + + local timeoutPromise = dependencies.delay(timeoutSeconds):andThen(function() + return { + kind = "timeout", + } + end) + + return Promise.race({ workerPromise, timeoutPromise }):andThen(function(event) + if event.kind == "completed" then + return event.payload + end + + -- This is a soft timeout. A yielding worker can be cancelled, but a + -- non-yielding infinite loop can block this controller until Studio's + -- own script watchdog intervenes. + local payload = failurePayload("timeout", string.format("Execution exceeded %.3g seconds", timeoutSeconds)) + return finalizeController(controller, payload, dependencies) + end) +end + +local Exec = {} +Exec.__index = Exec + +function Exec.new(options) + assert(type(options) == "table", "Exec options must be a table") + assert(type(options.apiContext) == "table", "Exec apiContext must be a table") + + local dependencies = options.dependencies or {} + local self = { + __apiContext = options.apiContext, + __isEdit = dependencies.isEdit or defaultExecutionDependencies.isEdit, + __delay = dependencies.delay or Promise.delay, + __execute = dependencies.execute or function(job) + return startExecution(job, defaultExecutionDependencies, EXECUTION_TIMEOUT_SECONDS) + end, + __onError = options.onError or function(errorValue) + Log.error("Prism exec poller failed: {}", errorValue) + end, + __running = false, + __generation = 0, + __busy = false, + __scheduledPromise = nil, + __executionPromise = nil, + __currentJobId = nil, + } + + return setmetatable(self, Exec) +end + +function Exec:__isCurrent(generation) + return self.__running and self.__generation == generation +end + +function Exec:__fail(generation, errorValue) + if not self:__isCurrent(generation) then + return + end + + self.__onError(errorValue) +end + +function Exec:__schedulePoll(generation, delaySeconds) + if not self:__isCurrent(generation) then + return + end + + local delayPromise = if delaySeconds == 0 then Promise.resolve() else self.__delay(delaySeconds) + self.__scheduledPromise = delayPromise + :andThen(function() + if self:__isCurrent(generation) then + self:__poll(generation) + end + end) + :catch(function(errorValue) + self:__fail(generation, errorValue) + end) +end + +function Exec:__releaseJobAndPoll(generation) + if not self:__isCurrent(generation) then + return + end + + self.__busy = false + self.__executionPromise = nil + self.__currentJobId = nil + self:__schedulePoll(generation, POLL_INTERVAL_SECONDS) +end + +function Exec:__complete(generation, jobId, payload, attempt) + if not self:__isCurrent(generation) then + return + end + + local requestOk, completionPromise = pcall(self.__apiContext.completeExecJob, self.__apiContext, jobId, payload) + if not requestOk then + self:__fail(generation, completionPromise) + return + end + + completionPromise + :andThen(function(result) + if not self:__isCurrent(generation) then + return + end + + if result.status == "conflict" then + Log.warn( + "Prism exec completion for job {} returned HTTP 409; treating it as already committed or expired", + jobId + ) + end + + self:__releaseJobAndPoll(generation) + end) + :catch(function(errorValue) + if not self:__isCurrent(generation) then + return + end + + if attempt >= COMPLETION_ATTEMPTS then + self:__fail( + generation, + string.format( + "Could not acknowledge Prism exec job %s after %d attempts: %s", + jobId, + COMPLETION_ATTEMPTS, + safeTostring(errorValue, tostring) + ) + ) + return + end + + self.__delay(COMPLETION_RETRY_DELAY_SECONDS):andThen(function() + if self:__isCurrent(generation) then + self:__complete(generation, jobId, payload, attempt + 1) + end + end) + end) +end + +function Exec:__executeClaimedJob(generation, job) + if not self:__isCurrent(generation) then + return + end + + self.__currentJobId = job.jobId + local executionOk, executionPromise = pcall(self.__execute, job) + if not executionOk then + self:__fail(generation, executionPromise) + return + end + + self.__executionPromise = executionPromise + executionPromise + :andThen(function(payload) + if self:__isCurrent(generation) then + self:__complete(generation, job.jobId, payload, 1) + end + end) + :catch(function(errorValue) + self:__fail(generation, errorValue) + end) +end + +function Exec:__poll(generation) + if not self:__isCurrent(generation) or self.__busy then + return + end + + if not self.__isEdit() then + self:__schedulePoll(generation, POLL_INTERVAL_SECONDS) + return + end + + self.__busy = true + local claimOk, claimPromise = pcall(self.__apiContext.claimNextExecJob, self.__apiContext) + if not claimOk then + self:__fail(generation, claimPromise) + return + end + + claimPromise + :andThen(function(job) + if not self:__isCurrent(generation) then + return + end + + if job == nil then + self.__busy = false + self:__schedulePoll(generation, POLL_INTERVAL_SECONDS) + return + end + + self:__executeClaimedJob(generation, job) + end) + :catch(function(errorValue) + self:__fail(generation, errorValue) + end) +end + +function Exec:start() + if self.__running then + return + end + + self.__running = true + self.__generation += 1 + self:__schedulePoll(self.__generation, 0) +end + +function Exec:stop() + if not self.__running then + return + end + + self.__running = false + self.__generation += 1 + + if self.__scheduledPromise ~= nil then + self.__scheduledPromise:cancel() + self.__scheduledPromise = nil + end + if self.__executionPromise ~= nil then + self.__executionPromise:cancel() + self.__executionPromise = nil + end + + self.__busy = false + self.__currentJobId = nil +end + +Exec._test = { + boundCompletionPayload = boundCompletionPayload, + buildWrapper = buildWrapper, + encodeExecValue = encodeExecValue, + makeExecutionController = makeExecutionController, + makeLogCollector = makeLogCollector, + makeProtectedOnce = makeProtectedOnce, + makeTemporaryName = makeTemporaryName, + runJob = runJob, + sanitizeScriptName = sanitizeScriptName, + startExecution = startExecution, + constants = { + completionAttempts = COMPLETION_ATTEMPTS, + maxCompletionBodyBytes = MAX_COMPLETION_BODY_BYTES, + maxLogEntries = MAX_LOG_ENTRIES, + maxLogEntryBytes = MAX_LOG_ENTRY_BYTES, + }, +} + +return Exec diff --git a/plugin/src/Exec.spec.lua b/plugin/src/Exec.spec.lua new file mode 100644 index 00000000..8f327ce7 --- /dev/null +++ b/plugin/src/Exec.spec.lua @@ -0,0 +1,713 @@ +return function() + local Packages = script.Parent.Parent.Packages + local Http = require(Packages.Http) + local Promise = require(Packages.Promise) + local Exec = require(script.Parent.Exec) + local Types = require(script.Parent.Types) + local helpers = Exec._test + + local function countPlainOccurrences(haystack, needle) + local count = 0 + local position = 1 + while true do + local first, last = haystack:find(needle, position, true) + if first == nil then + return count + end + count += 1 + position = last + 1 + end + end + + local function makeRunDependencies() + local state = { + cleanupCount = 0, + editCheckCount = 0, + finishCount = 0, + invocationCount = 0, + loaded = function() + return nil + end, + recording = "recording", + } + local dependencies = { + isEdit = function() + state.editCheckCount += 1 + return true + end, + generateUniqueToken = function() + return "test-token" + end, + createTemporaryModule = function() + return {} + end, + configureTemporaryModule = function(temporaryModule, name, wrapper) + temporaryModule.name = name + temporaryModule.wrapper = wrapper + end, + requireTemporaryModule = function() + return function(rojoExec) + state.invocationCount += 1 + return state.loaded(rojoExec) + end + end, + tryBeginRecording = function() + return state.recording + end, + finishRecording = function() + state.finishCount += 1 + end, + cleanupTemporaryModule = function() + state.cleanupCount += 1 + end, + traceback = function(message) + return "traceback: " .. message + end, + stringify = tostring, + encodePayload = Http.msgpackEncode, + } + + return dependencies, state + end + + local function runWith(dependencies, source, scriptName) + local job = { + jobId = "00000000-0000-0000-0000-000000000000", + scriptName = scriptName or "test.lua", + source = source or "return nil", + state = "claimed", + } + local controller = helpers.makeExecutionController(dependencies) + return helpers.runJob(job, dependencies, controller) + end + + local function makeScheduler() + local queue = {} + return { + delay = function() + return Promise.new(function(resolve) + table.insert(queue, resolve) + end) + end, + step = function() + local resolve = table.remove(queue, 1) + assert(resolve ~= nil, "no scheduled delay to resolve") + resolve() + end, + count = function() + return #queue + end, + } + end + + local function claimedJob() + return { + jobId = "00000000-0000-0000-0000-000000000001", + scriptName = "test.lua", + source = "return true", + state = "claimed", + } + end + + describe("Exec wrapper and names", function() + it("contains submitted source exactly once", function() + local source = "local uniqueMarker = 42\nreturn uniqueMarker" + expect(countPlainOccurrences(helpers.buildWrapper(source), source)).to.equal(1) + end) + + it("supports top-level return and shadows plugin", function() + local wrapper = helpers.buildWrapper("return true") + expect(wrapper:find("return function(rojoExec)", 1, true)).to.equal(1) + expect(wrapper:find("local plugin = nil", 1, true)).to.be.ok() + expect(wrapper:find("\nreturn true\nend\n", 1, true)).to.be.ok() + end) + + it("sanitizes and uniquifies temporary names", function() + local sanitized = helpers.sanitizeScriptName("bad/name\0\n with spaces") + local first = helpers.makeTemporaryName(sanitized, "token") + local second = helpers.makeTemporaryName(sanitized, "token") + expect(sanitized:find("[^%w%._%-]")).to.equal(nil) + expect(first).never.to.equal(second) + end) + end) + + describe("Exec result encoding", function() + it("encodes nil", function() + local encoded = helpers.encodeExecValue(nil) + expect(encoded.kind).to.equal("nil") + end) + + it("encodes strings", function() + local encoded = helpers.encodeExecValue("hello") + expect(encoded.kind).to.equal("string") + expect(encoded.value).to.equal("hello") + end) + + it("encodes numbers", function() + local encoded = helpers.encodeExecValue(4.25) + expect(encoded.kind).to.equal("number") + expect(encoded.value).to.equal(4.25) + end) + + it("encodes booleans", function() + local encoded = helpers.encodeExecValue(false) + expect(encoded.kind).to.equal("boolean") + expect(encoded.value).to.equal(false) + end) + + it("encodes dense arrays in order", function() + local encoded = helpers.encodeExecValue({ "first", 2, true }) + expect(encoded.kind).to.equal("array") + expect(encoded.value[1].kind).to.equal("string") + expect(encoded.value[1].value).to.equal("first") + expect(encoded.value[2].kind).to.equal("number") + expect(encoded.value[2].value).to.equal(2) + expect(encoded.value[3].kind).to.equal("boolean") + expect(encoded.value[3].value).to.equal(true) + end) + + it("sorts string-key tables deterministically", function() + local encoded = helpers.encodeExecValue({ zed = 1, alpha = 2 }) + expect(encoded.kind).to.equal("table") + expect(encoded.value[1].key).to.equal("alpha") + expect(encoded.value[2].key).to.equal("zed") + end) + + it("rejects cycles", function() + local value = {} + value.self = value + local encoded, errorMessage = helpers.encodeExecValue(value) + expect(encoded).to.equal(nil) + expect(errorMessage:find("cycle", 1, true)).to.be.ok() + end) + + it("rejects sparse arrays", function() + local encoded, errorMessage = helpers.encodeExecValue({ [1] = true, [3] = true }) + expect(encoded).to.equal(nil) + expect(errorMessage:find("sparse", 1, true)).to.be.ok() + end) + + it("rejects mixed array and dictionary tables", function() + local encoded, errorMessage = helpers.encodeExecValue({ [1] = true, named = true }) + expect(encoded).to.equal(nil) + expect(errorMessage:find("mixes", 1, true)).to.be.ok() + end) + + it("rejects non-string dictionary keys", function() + local encoded, errorMessage = helpers.encodeExecValue({ [false] = true }) + expect(encoded).to.equal(nil) + expect(errorMessage:find("key type", 1, true)).to.be.ok() + end) + + it("rejects Instances", function() + local instance = Instance.new("Folder") + local encoded, errorMessage = helpers.encodeExecValue(instance) + instance:Destroy() + expect(encoded).to.equal(nil) + expect(errorMessage:find("Instance", 1, true)).to.be.ok() + end) + + it("rejects non-finite numbers", function() + local nanEncoded, nanError = helpers.encodeExecValue(0 / 0) + local infinityEncoded, infinityError = helpers.encodeExecValue(math.huge) + expect(nanEncoded).to.equal(nil) + expect(nanError:find("non-finite", 1, true)).to.be.ok() + expect(infinityEncoded).to.equal(nil) + expect(infinityError:find("non-finite", 1, true)).to.be.ok() + end) + end) + + describe("Exec logs", function() + it("preserves print and warn sequence", function() + local collector = helpers.makeLogCollector(tostring) + collector:add("print", "hello", 2) + collector:add("warn", "careful") + collector:add("print", "done") + local logs = collector:finish() + expect(#logs).to.equal(3) + expect(logs[1].level).to.equal("print") + expect(logs[1].message).to.equal("hello\t2") + expect(logs[2].level).to.equal("warn") + expect(logs[2].message).to.equal("careful") + expect(logs[3].level).to.equal("print") + expect(logs[3].message).to.equal("done") + end) + + it("bounds oversized entries and adds a warning", function() + local collector = helpers.makeLogCollector(tostring) + collector:add("print", string.rep("x", helpers.constants.maxLogEntryBytes + 100)) + local logs = collector:finish() + expect(#logs).to.equal(2) + expect(#logs[1].message <= helpers.constants.maxLogEntryBytes).to.equal(true) + expect(logs[2].level).to.equal("warn") + end) + + it("never exceeds the entry count limit", function() + local collector = helpers.makeLogCollector(tostring) + for index = 1, helpers.constants.maxLogEntries + 1 do + collector:add("print", index) + end + local logs = collector:finish() + expect(#logs).to.equal(helpers.constants.maxLogEntries) + expect(logs[#logs].level).to.equal("warn") + end) + end) + + describe("Exec controller", function() + it("posts a scalar success result", function() + local dependencies, state = makeRunDependencies() + state.loaded = function() + return true + end + local payload = runWith(dependencies) + expect(payload.outcome).to.equal("success") + expect(payload.result.kind).to.equal("boolean") + expect(payload.result.value).to.equal(true) + end) + + it("lets the last explicit output override the return value", function() + local dependencies, state = makeRunDependencies() + state.loaded = function(rojoExec) + rojoExec.output("first") + rojoExec.output("last") + return "returned" + end + local payload = runWith(dependencies) + expect(payload.result.kind).to.equal("string") + expect(payload.result.value).to.equal("last") + end) + + it("includes helper logs in a success payload", function() + local dependencies, state = makeRunDependencies() + state.loaded = function(rojoExec) + rojoExec.print("one") + rojoExec.warn("two") + end + local payload = runWith(dependencies) + expect(#payload.logs).to.equal(2) + expect(payload.logs[1].level).to.equal("print") + expect(payload.logs[1].message).to.equal("one") + expect(payload.logs[2].level).to.equal("warn") + expect(payload.logs[2].message).to.equal("two") + end) + + it("classifies require errors as compile failures", function() + local dependencies, state = makeRunDependencies() + dependencies.requireTemporaryModule = function() + error("Requested module experienced an error while loading") + end + local payload = runWith(dependencies, "local =", "compile.lua") + expect(payload.outcome).to.equal("compileFailure") + expect(payload.error:find("Requested module experienced", 1, true)).to.be.ok() + expect(payload.error:find("__RojoExec_", 1, true)).to.be.ok() + expect(state.invocationCount).to.equal(0) + end) + + it("captures runtime failures and tracebacks", function() + local dependencies, state = makeRunDependencies() + state.loaded = function() + error("intentional runtime failure") + end + local payload = runWith(dependencies) + expect(payload.outcome).to.equal("runtimeFailure") + expect(payload.error:find("intentional runtime failure", 1, true)).to.be.ok() + expect(payload.traceback:find("traceback:", 1, true)).to.be.ok() + end) + + it("starts and finishes one recording", function() + local dependencies, state = makeRunDependencies() + local payload = runWith(dependencies) + expect(payload.outcome).to.equal("success") + expect(state.finishCount).to.equal(1) + end) + + it("commits the recording after a partial runtime failure", function() + local dependencies, state = makeRunDependencies() + state.loaded = function() + error("after mutation") + end + local payload = runWith(dependencies) + expect(payload.outcome).to.equal("runtimeFailure") + expect(state.finishCount).to.equal(1) + end) + + it("cleans up exactly once", function() + local dependencies, state = makeRunDependencies() + local controller = helpers.makeExecutionController(dependencies) + helpers.runJob(claimedJob(), dependencies, controller) + controller.cleanup() + expect(state.cleanupCount).to.equal(1) + end) + + it("does not invoke when recording cannot start", function() + local dependencies, state = makeRunDependencies() + state.recording = nil + local payload = runWith(dependencies) + expect(payload.outcome).to.equal("rejected") + expect(state.invocationCount).to.equal(0) + expect(state.finishCount).to.equal(0) + end) + + it("rejects an edit-to-play race before invocation", function() + local dependencies, state = makeRunDependencies() + dependencies.isEdit = function() + state.editCheckCount += 1 + return state.editCheckCount < 3 + end + local payload = runWith(dependencies) + expect(payload.outcome).to.equal("rejected") + expect(state.invocationCount).to.equal(0) + expect(state.finishCount).to.equal(1) + end) + + it("returns a timeout payload and cancels the worker", function() + local dependencies, state = makeRunDependencies() + local releaseWorker = Instance.new("BindableEvent") + local resolveInvocation + local invocationPromise = Promise.new(function(resolve) + resolveInvocation = resolve + end) + local resolveTimeout + state.loaded = function() + resolveInvocation() + releaseWorker.Event:Wait() + end + dependencies.spawn = task.spawn + dependencies.cancel = task.cancel + dependencies.delay = function() + return Promise.new(function(resolve) + resolveTimeout = resolve + end) + end + + local executionPromise = helpers.startExecution(claimedJob(), dependencies, 0.01) + expect(invocationPromise:await()).to.equal(true) + resolveTimeout() + local success, payload = executionPromise:await() + releaseWorker:Destroy() + + expect(success).to.equal(true) + expect(payload.outcome).to.equal("timeout") + expect(state.finishCount).to.equal(1) + expect(state.cleanupCount).to.equal(1) + end) + end) + + describe("Exec poller", function() + it("starts only once", function() + local scheduler = makeScheduler() + local claimCount = 0 + local poller = Exec.new({ + apiContext = { + claimNextExecJob = function() + claimCount += 1 + return Promise.resolve(nil) + end, + }, + dependencies = { + delay = scheduler.delay, + isEdit = function() + return true + end, + }, + }) + poller:start() + poller:start() + expect(claimCount).to.equal(1) + poller:stop() + end) + + it("treats an empty claim as idle polling", function() + local scheduler = makeScheduler() + local executeCount = 0 + local poller = Exec.new({ + apiContext = { + claimNextExecJob = function() + return Promise.resolve(nil) + end, + }, + dependencies = { + delay = scheduler.delay, + isEdit = function() + return true + end, + execute = function() + executeCount += 1 + return Promise.resolve() + end, + }, + }) + poller:start() + expect(executeCount).to.equal(0) + expect(scheduler.count()).to.equal(1) + poller:stop() + end) + + it("claims and executes one job exactly once", function() + local scheduler = makeScheduler() + local executeCount = 0 + local completeCount = 0 + local api = { + claimNextExecJob = function() + return Promise.resolve(claimedJob()) + end, + completeExecJob = function() + completeCount += 1 + return Promise.resolve({ status = "accepted" }) + end, + } + local poller = Exec.new({ + apiContext = api, + dependencies = { + delay = scheduler.delay, + isEdit = function() + return true + end, + execute = function() + executeCount += 1 + return Promise.resolve({ outcome = "success", result = { kind = "nil" }, logs = {} }) + end, + }, + }) + poller:start() + expect(executeCount).to.equal(1) + expect(completeCount).to.equal(1) + poller:stop() + end) + + it("does not claim again while execution is pending", function() + local scheduler = makeScheduler() + local claimCount = 0 + local resolveExecution + local poller = Exec.new({ + apiContext = { + claimNextExecJob = function() + claimCount += 1 + return Promise.resolve(claimedJob()) + end, + }, + dependencies = { + delay = scheduler.delay, + isEdit = function() + return true + end, + execute = function() + return Promise.new(function(resolve) + resolveExecution = resolve + end) + end, + }, + }) + poller:start() + poller:__poll(poller.__generation) + expect(claimCount).to.equal(1) + poller:stop() + resolveExecution({ outcome = "success", logs = {} }) + end) + + it("stops scheduled polling", function() + local scheduler = makeScheduler() + local claimCount = 0 + local poller = Exec.new({ + apiContext = { + claimNextExecJob = function() + claimCount += 1 + return Promise.resolve(nil) + end, + }, + dependencies = { + delay = scheduler.delay, + isEdit = function() + return true + end, + }, + }) + poller:start() + poller:stop() + scheduler.step() + expect(claimCount).to.equal(1) + end) + + it("ignores a late claim after stop", function() + local resolveClaim + local executeCount = 0 + local poller = Exec.new({ + apiContext = { + claimNextExecJob = function() + return Promise.new(function(resolve) + resolveClaim = resolve + end) + end, + }, + dependencies = { + isEdit = function() + return true + end, + execute = function() + executeCount += 1 + return Promise.resolve() + end, + }, + }) + poller:start() + poller:stop() + resolveClaim(claimedJob()) + expect(executeCount).to.equal(0) + end) + + it("cancels active execution when the session stops", function() + local cancellationCount = 0 + local completionCount = 0 + local poller = Exec.new({ + apiContext = { + claimNextExecJob = function() + return Promise.resolve(claimedJob()) + end, + completeExecJob = function() + completionCount += 1 + return Promise.resolve({ status = "accepted" }) + end, + }, + dependencies = { + isEdit = function() + return true + end, + execute = function() + return Promise.new(function(_resolve, _reject, onCancel) + onCancel(function() + cancellationCount += 1 + end) + end) + end, + }, + }) + poller:start() + poller:stop() + expect(cancellationCount).to.equal(1) + expect(completionCount).to.equal(0) + end) + + it("does not claim in play mode and resumes in edit mode", function() + local scheduler = makeScheduler() + local edit = false + local claimCount = 0 + local poller = Exec.new({ + apiContext = { + claimNextExecJob = function() + claimCount += 1 + return Promise.resolve(nil) + end, + }, + dependencies = { + delay = scheduler.delay, + isEdit = function() + return edit + end, + }, + }) + poller:start() + expect(claimCount).to.equal(0) + edit = true + scheduler.step() + expect(claimCount).to.equal(1) + poller:stop() + end) + + it("retries completion without re-executing", function() + local scheduler = makeScheduler() + local executeCount = 0 + local completeCount = 0 + local poller = Exec.new({ + apiContext = { + claimNextExecJob = function() + return Promise.resolve(claimedJob()) + end, + completeExecJob = function() + completeCount += 1 + if completeCount < 3 then + return Promise.reject("transport failure") + end + return Promise.resolve({ status = "accepted" }) + end, + }, + dependencies = { + delay = scheduler.delay, + isEdit = function() + return true + end, + execute = function() + executeCount += 1 + return Promise.resolve({ outcome = "success", logs = {} }) + end, + }, + }) + poller:start() + scheduler.step() + scheduler.step() + expect(completeCount).to.equal(3) + expect(executeCount).to.equal(1) + poller:stop() + end) + + it("treats completion conflict as terminal without re-executing", function() + local scheduler = makeScheduler() + local executeCount = 0 + local poller = Exec.new({ + apiContext = { + claimNextExecJob = function() + return Promise.resolve(claimedJob()) + end, + completeExecJob = function() + return Promise.resolve({ status = "conflict" }) + end, + }, + dependencies = { + delay = scheduler.delay, + isEdit = function() + return true + end, + execute = function() + executeCount += 1 + return Promise.resolve({ outcome = "success", logs = {} }) + end, + }, + }) + poller:start() + expect(executeCount).to.equal(1) + poller:stop() + end) + + it("routes malformed claim failures to the session error callback", function() + local capturedError = nil + local poller = Exec.new({ + apiContext = { + claimNextExecJob = function() + return Promise.reject("Prism exec protocol error: malformed claimed-job response") + end, + }, + dependencies = { + isEdit = function() + return true + end, + }, + onError = function(errorValue) + capturedError = errorValue + end, + }) + poller:start() + expect(tostring(capturedError):find("protocol error", 1, true)).to.be.ok() + poller:stop() + end) + + it("validates the exact claimed-job wire shape", function() + local valid = Types.ApiExecClaimResponse(claimedJob()) + local malformed = Types.ApiExecClaimResponse({ + jobId = claimedJob().jobId, + scriptName = "test.lua", + state = "claimed", + }) + expect(valid).to.equal(true) + expect(malformed).to.equal(false) + end) + end) +end diff --git a/plugin/src/PatchSet.lua b/plugin/src/PatchSet.lua index 3f6ece3c..368d00f6 100644 --- a/plugin/src/PatchSet.lua +++ b/plugin/src/PatchSet.lua @@ -375,7 +375,7 @@ function PatchSet.humanSummary(instanceMap, patchSet) local updatedProperties = {} if update.changedMetadata ~= nil then - table.insert(updatedProperties, "Rojo's Metadata") + table.insert(updatedProperties, "Prism's Metadata") end if update.changedName ~= nil then diff --git a/plugin/src/ServeSession.lua b/plugin/src/ServeSession.lua index 7e9fc742..58aef5de 100644 --- a/plugin/src/ServeSession.lua +++ b/plugin/src/ServeSession.lua @@ -13,6 +13,7 @@ local Timer = require(script.Parent.Timer) local ChangeBatcher = require(script.Parent.ChangeBatcher) local encodePatchUpdate = require(script.Parent.ChangeBatcher.encodePatchUpdate) +local Exec = require(script.Parent.Exec) local InstanceMap = require(script.Parent.InstanceMap) local PatchSet = require(script.Parent.PatchSet) local Reconciler = require(script.Parent.Reconciler) @@ -113,6 +114,15 @@ function ServeSession.new(options) setmetatable(self, ServeSession) + self.__exec = Exec.new({ + apiContext = self.__apiContext, + onError = function(errorValue) + if self.__status ~= Status.Disconnected then + self:__stopInternal(errorValue) + end + end, + }) + return self end @@ -202,13 +212,13 @@ function ServeSession:start() self:__setStatus(Status.Connected, serverInfo.projectName) self:__applyGameAndPlaceId(serverInfo) - return self.__apiContext:connectWebSocket({ + local websocketPromise = self.__apiContext:connectWebSocket({ ["messages"] = function(messagesPacket) if self.__status == Status.Disconnected then return end - Log.debug("Received {} messages from Rojo server", #messagesPacket.messages) + Log.debug("Received {} messages from Prism server", #messagesPacket.messages) for _, message in messagesPacket.messages do self:__applyPatch(message) @@ -216,6 +226,11 @@ function ServeSession:start() self.__apiContext:setMessageCursor(messagesPacket.messageCursor) end, }) + + -- connectWebSocket creates the stream client synchronously. Starting + -- here keeps exec on the same stable, fully-synced session lifecycle. + self.__exec:start() + return websocketPromise end) end) :catch(function(err) @@ -254,7 +269,7 @@ function ServeSession:__onActiveScriptChanged(activeScript) local scriptId = self.__instanceMap.fromInstances[activeScript] if scriptId == nil then - Log.trace("Not opening script {} because it is not known by Rojo.", activeScript) + Log.trace("Not opening script {} because it is not known by Prism.", activeScript) return end @@ -406,7 +421,7 @@ end function ServeSession:__applyPatch(patch) local patchTimestamp = DateTime.now():FormatLocalTime("LTS", "en-us") - local historyRecording = ChangeHistoryService:TryBeginRecording("Rojo: Patch " .. patchTimestamp) + local historyRecording = ChangeHistoryService:TryBeginRecording("Prism: Patch " .. patchTimestamp) if not historyRecording then -- There can only be one recording at a time Log.debug("Failed to begin history recording for " .. patchTimestamp .. ". Another recording is in progress.") @@ -461,7 +476,7 @@ function ServeSession:__applyPatch(patch) if not PatchSet.isEmpty(unappliedPatch) then Log.debug( - "Could not apply all changes requested by the Rojo server:\n{}", + "Could not apply all changes requested by the Prism server:\n{}", PatchSet.humanSummary(self.__instanceMap, unappliedPatch) ) end @@ -492,7 +507,7 @@ function ServeSession:__initialSync(serverInfo) -- For any instances that line up with the Rojo server's view, start -- tracking them in the reconciler. - Log.trace("Matching existing Roblox instances to Rojo IDs") + Log.trace("Matching existing Roblox instances to Prism IDs") self:setLoadingText("Hydrating instance map...") self.__reconciler:hydrate(readResponseBody.instances, serverInfo.rootInstanceId, game) @@ -504,7 +519,7 @@ function ServeSession:__initialSync(serverInfo) self.__reconciler:diff(readResponseBody.instances, serverInfo.rootInstanceId, game) if not success then - Log.error("Could not compute a diff to catch up to the Rojo server: {:#?}", catchUpPatch) + Log.error("Could not compute a diff to catch up to the Prism server: {:#?}", catchUpPatch) end for _, update in catchUpPatch.updated do @@ -514,7 +529,7 @@ function ServeSession:__initialSync(serverInfo) -- message instead of crashing. return Promise.reject( "Cannot sync a model as a place." - .. "\nEnsure Rojo is serving a project file that has a DataModel at the root of its tree and try again." + .. "\nEnsure Prism is serving a project file that has a DataModel at the root of its tree and try again." .. "\nSee project file docs: https://rojo.space/docs/v7/project-format/" ) end @@ -528,7 +543,7 @@ function ServeSession:__initialSync(serverInfo) end if userDecision == "Abort" then - return Promise.reject("Aborted Rojo sync operation") + return Promise.reject("Aborted Prism sync operation") elseif userDecision == "Reject" then if not self.__twoWaySync then return Promise.reject("Cannot reject sync operation without two-way sync enabled") @@ -569,6 +584,7 @@ function ServeSession:__initialSync(serverInfo) end function ServeSession:__stopInternal(err) + self.__exec:stop() self:__setStatus(Status.Disconnected, err) self.__apiContext:disconnect() self.__instanceMap:stop() diff --git a/plugin/src/Timer.lua b/plugin/src/Timer.lua index 9a5bbbd0..3ae18517 100644 --- a/plugin/src/Timer.lua +++ b/plugin/src/Timer.lua @@ -9,7 +9,7 @@ local Timer = { function Timer._start(label) local start = clock() if not label then - error("[Rojo-Timer] Timer.start: label is required", 2) + error("[Prism-Timer] Timer.start: label is required", 2) return end @@ -21,7 +21,7 @@ function Timer._stop() local entry = table.remove(Timer._entries) if not entry then - error("[Rojo-Timer] Timer.stop: no label to stop", 2) + error("[Prism-Timer] Timer.stop: no label to stop", 2) return end @@ -36,7 +36,7 @@ function Timer._stop() local start = entry[2] local duration = stop - start - print(string.format("[Rojo-Timer] %s took %.3f ms", label, duration * 1000)) + print(string.format("[Prism-Timer] %s took %.3f ms", label, duration * 1000)) end -- Replace functions with no-op if not in debug mode diff --git a/plugin/src/Types.lua b/plugin/src/Types.lua index fd04bed2..52db9886 100644 --- a/plugin/src/Types.lua +++ b/plugin/src/Types.lua @@ -74,6 +74,102 @@ local ApiRefPatchResponse = t.interface({ patch = ApiSubscribeMessage, }) +local function Uuid(value) + if type(value) ~= "string" then + return false, string.format("string expected, got %s", type(value)) + end + + local first, second, third, fourth, fifth = + value:match("^([0-9a-fA-F]+)%-([0-9a-fA-F]+)%-([0-9a-fA-F]+)%-([0-9a-fA-F]+)%-([0-9a-fA-F]+)$") + if first == nil or #first ~= 8 or #second ~= 4 or #third ~= 4 or #fourth ~= 4 or #fifth ~= 12 then + return false, "UUID string expected" + end + + return true +end + +local ExecJobState = t.union( + t.literal("pending"), + t.literal("claimed"), + t.literal("succeeded"), + t.literal("failed"), + t.literal("timedOut") +) + +local ExecLogLevel = t.union(t.literal("print"), t.literal("warn")) + +local ExecLog = t.strictInterface({ + level = ExecLogLevel, + message = t.string, +}) + +local function ExecValue(value, seen) + if type(value) ~= "table" then + return false, string.format("exec result table expected, got %s", type(value)) + end + + seen = seen or {} + if seen[value] then + return false, "cyclic exec result value" + end + seen[value] = true + + local kind = value.kind + local valid, message + if kind == "nil" then + valid, message = t.strictInterface({ kind = t.literal("nil") })(value) + elseif kind == "string" then + valid, message = t.strictInterface({ kind = t.literal("string"), value = t.string })(value) + elseif kind == "number" then + valid, message = t.strictInterface({ kind = t.literal("number"), value = t.number })(value) + elseif kind == "boolean" then + valid, message = t.strictInterface({ kind = t.literal("boolean"), value = t.boolean })(value) + elseif kind == "array" then + valid, message = t.strictInterface({ kind = t.literal("array"), value = t.table })(value) + if valid then + valid, message = t.array(function(child) + return ExecValue(child, seen) + end)(value.value) + end + elseif kind == "table" then + valid, message = t.strictInterface({ kind = t.literal("table"), value = t.table })(value) + if valid then + valid, message = t.array(function(entry) + local entryValid, entryMessage = t.strictInterface({ + key = t.string, + value = t.table, + })(entry) + if not entryValid then + return false, entryMessage + end + return ExecValue(entry.value, seen) + end)(value.value) + end + else + valid, message = false, string.format("unknown exec result kind %q", tostring(kind)) + end + + seen[value] = nil + return valid, message +end + +local ApiExecClaimResponse = t.strictInterface({ + jobId = Uuid, + scriptName = t.string, + source = t.string, + state = t.literal("claimed"), +}) + +local ApiExecJobResponse = t.strictInterface({ + jobId = Uuid, + scriptName = t.string, + state = ExecJobState, + result = t.optional(ExecValue), + logs = t.optional(t.array(ExecLog)), + error = t.optional(t.string), + traceback = t.optional(t.string), +}) + local ApiError = t.interface({ kind = t.union(t.literal("NotFound"), t.literal("BadRequest"), t.literal("InternalError")), details = t.string, @@ -103,6 +199,13 @@ return strict("Types", { ApiSubscribeMessage = ApiSubscribeMessage, ApiSerializeResponse = ApiSerializeResponse, ApiRefPatchResponse = ApiRefPatchResponse, + ApiExecClaimResponse = ApiExecClaimResponse, + ApiExecJobResponse = ApiExecJobResponse, + ExecJobState = ExecJobState, + ExecValue = ExecValue, + ExecLog = ExecLog, + ExecLogLevel = ExecLogLevel, + Uuid = Uuid, ApiValue = ApiValue, RbxId = RbxId, diff --git a/plugin/src/init.server.lua b/plugin/src/init.server.lua index c14487ff..e4dacfce 100644 --- a/plugin/src/init.server.lua +++ b/plugin/src/init.server.lua @@ -2,6 +2,7 @@ if not plugin then return end +-- Keep the packaged root name for compatibility with existing module lookups. local Rojo = script:FindFirstAncestor("Rojo") local Packages = Rojo.Packages diff --git a/plugin/src/invariant.lua b/plugin/src/invariant.lua index d1b7ccc8..cbdc2dfb 100644 --- a/plugin/src/invariant.lua +++ b/plugin/src/invariant.lua @@ -16,9 +16,9 @@ else message = Fmt.fmt(message, ...) local fullMessage = string.format( - "Rojo detected an invariant violation within itself:\n" + "Prism detected an invariant violation within itself:\n" .. "%s\n\n" - .. "This is a bug in Rojo. Please file an issue:\n" + .. "This is a bug in Prism. Please file an issue:\n" .. "https://github.com/rojo-rbx/rojo/issues", message ) diff --git a/plugin/src/soundPlayer.lua b/plugin/src/soundPlayer.lua index 32592720..d4de7678 100644 --- a/plugin/src/soundPlayer.lua +++ b/plugin/src/soundPlayer.lua @@ -7,7 +7,7 @@ if plugin then DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, false, true, 10, 10, 10, 10) ) widget.Name = "Rojo_soundPlayer" - widget.Title = "Rojo Sound Player" + widget.Title = "Prism Sound Player" end local SoundPlayer = {} diff --git a/src/cli/exec.rs b/src/cli/exec.rs index d08408b7..3b14419e 100644 --- a/src/cli/exec.rs +++ b/src/cli/exec.rs @@ -39,7 +39,7 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); const MAX_RESPONSE_SIZE_BYTES: usize = 2 * MAX_SOURCE_SIZE_BYTES; const MSGPACK_CONTENT_TYPE: &str = "application/msgpack"; -/// Runs a trusted Luau file through a connected Rojo Studio plugin. +/// Runs a trusted Luau file through a connected Prism Studio plugin. #[derive(Debug, Parser)] pub struct ExecCommand { /// Path to the Luau file to execute. @@ -62,7 +62,7 @@ impl ExecCommand { .connect_timeout(CONNECT_TIMEOUT) .timeout(REQUEST_TIMEOUT) .build() - .context("Could not create the HTTP client for rojo exec")?; + .context("Could not create the HTTP client for Prism exec")?; verify_rojo_server(&client, &server_url)?; let submitted = submit_job(&client, &server_url, &script)?; @@ -95,7 +95,7 @@ fn read_exec_script(path: &Path) -> anyhow::Result { bail!("Exec script '{}' is not a regular file.", path.display()); } - if metadata.len() > MAX_SOURCE_SIZE_BYTES as u64 { + if metadata.len() > (MAX_SOURCE_SIZE_BYTES + '\u{FEFF}'.len_utf8()) as u64 { bail!( "Exec script '{}' is {} bytes, exceeding the {}-byte source limit.", path.display(), @@ -108,6 +108,10 @@ fn read_exec_script(path: &Path) -> anyhow::Result { let source = std::fs::read(path.as_ref()) .with_context(|| format!("Could not read exec script '{}'.", path.display()))?; + let mut source = String::from_utf8(source) + .with_context(|| format!("Exec script '{}' is not valid UTF-8.", path.display()))?; + normalize_source(&mut source); + if source.len() > MAX_SOURCE_SIZE_BYTES { bail!( "Exec script '{}' is {} bytes, exceeding the {}-byte source limit.", @@ -117,15 +121,18 @@ fn read_exec_script(path: &Path) -> anyhow::Result { ); } - let source = String::from_utf8(source) - .with_context(|| format!("Exec script '{}' is not valid UTF-8.", path.display()))?; - Ok(ExecScript { script_name, source, }) } +fn normalize_source(source: &mut String) { + if source.starts_with('\u{FEFF}') { + source.replace_range(..'\u{FEFF}'.len_utf8(), ""); + } +} + fn script_name_from_path(path: &Path) -> anyhow::Result { let file_name = path .file_name() @@ -181,7 +188,7 @@ fn verify_rojo_server(client: &Client, server_url: &str) -> anyhow::Result<()> { if info.protocol_version != PROTOCOL_VERSION { bail!( - "The Rojo server at {server_url} uses protocol version {}, but this rojo exec client requires version {}.", + "The Rojo server at {server_url} uses protocol version {}, but this Prism exec client requires version {}.", info.protocol_version, PROTOCOL_VERSION ); @@ -388,11 +395,11 @@ fn decode_buffered_exec_response( fn exec_http_error(response: &BufferedResponse, operation: &str) -> anyhow::Error { let summary = match response.status { StatusCode::BAD_REQUEST => "Rojo server rejected the request as malformed", - StatusCode::FORBIDDEN => "Rojo exec API is not available from this peer", - StatusCode::NOT_FOUND => "Rojo exec job disappeared or expired", - StatusCode::CONFLICT => "Rojo exec job has an unexpected state conflict", - StatusCode::PAYLOAD_TOO_LARGE => "Rojo exec source or output is too large", - StatusCode::TOO_MANY_REQUESTS => "Rojo exec queue is full", + StatusCode::FORBIDDEN => "Prism exec API is not available from this peer", + StatusCode::NOT_FOUND => "Prism exec job disappeared or expired", + StatusCode::CONFLICT => "Prism exec job has an unexpected state conflict", + StatusCode::PAYLOAD_TOO_LARGE => "Prism exec source or output is too large", + StatusCode::TOO_MANY_REQUESTS => "Prism exec queue is full", StatusCode::INTERNAL_SERVER_ERROR => "Rojo server reported an internal error", _ => "Rojo server returned an unexpected HTTP status", }; @@ -480,9 +487,9 @@ fn terminal_failure_message(job: &ExecJobResponse, timed_out: bool) -> String { .filter(|error| !error.is_empty()) .unwrap_or(fallback); let mut message = if timed_out { - format!("Rojo exec timed out: {error}") + format!("Prism exec timed out: {error}") } else { - format!("Rojo exec failed: {error}") + format!("Prism exec failed: {error}") }; if let Some(traceback) = job @@ -683,6 +690,65 @@ mod test { ); } + #[test] + fn normalization_preserves_source_without_bom() { + let mut source = "return true".to_owned(); + + normalize_source(&mut source); + + assert_eq!(source, "return true"); + } + + #[test] + fn normalization_removes_one_leading_bom() { + let mut source = "\u{FEFF}return true".to_owned(); + + normalize_source(&mut source); + + assert_eq!(source, "return true"); + } + + #[test] + fn normalization_preserves_interior_bom() { + let mut source = "print(\"\u{FEFF}\")".to_owned(); + + normalize_source(&mut source); + + assert_eq!(source, "print(\"\u{FEFF}\")"); + } + + #[test] + fn normalization_turns_single_bom_into_empty_source() { + let mut source = "\u{FEFF}".to_owned(); + + normalize_source(&mut source); + + assert_eq!(source, ""); + } + + #[test] + fn normalization_removes_only_first_of_two_leading_boms() { + let mut source = "\u{FEFF}\u{FEFF}return true".to_owned(); + + normalize_source(&mut source); + + assert_eq!(source, "\u{FEFF}return true"); + } + + #[test] + fn source_limit_applies_after_bom_normalization() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("maximum-size.lua"); + let mut source = "\u{FEFF}".as_bytes().to_vec(); + source.extend(std::iter::repeat_n(b'x', MAX_SOURCE_SIZE_BYTES)); + fs::write(&path, source).unwrap(); + + let script = read_exec_script(&path).unwrap(); + + assert_eq!(script.source.len(), MAX_SOURCE_SIZE_BYTES); + assert!(!script.source.starts_with('\u{FEFF}')); + } + #[test] fn rejects_invalid_utf8_source() { let dir = tempfile::tempdir().unwrap(); @@ -861,7 +927,7 @@ mod test { let error = finish_job(&response, &mut Vec::new(), &mut Vec::new()).unwrap_err(); assert_eq!( error.to_string(), - "Rojo exec failed: attempt to index nil\nstack traceback:\n test.lua:1" + "Prism exec failed: attempt to index nil\nstack traceback:\n test.lua:1" ); } @@ -873,7 +939,7 @@ mod test { let error = finish_job(&response, &mut Vec::new(), &mut Vec::new()).unwrap_err(); assert_eq!( error.to_string(), - "Rojo exec timed out: execution exceeded its deadline" + "Prism exec timed out: execution exceeded its deadline" ); }