diff --git a/.lune/test.luau b/.lune/test.luau new file mode 100644 index 0000000..6a36cab --- /dev/null +++ b/.lune/test.luau @@ -0,0 +1,23 @@ +-- lune run build dev yes && rojo build ./tests.project.json --output tests.rbxlx && run-in-roblox.exe --script ./tests/runner.luau --place ./tests.rbxlx + +local process = require("@lune/process") + +local function ensure(result: process.SpawnResult) + if not result.ok then + error(`got {result.code}, stderr: {result.stderr}, stdout: {result.stdout}`, 2) + end + + return result +end + +ensure(process.spawn("lune", { + "run", + "build", + "dev", + "yes", +})) + +ensure(process.spawn("rojo", { "build", "./tests.project.json", "--output", "tests.rbxlx" })) +print(ensure(process.spawn("run-in-roblox", { "--script", "./tests/pluginRun.luau", "--place", "tests.rbxlx" }, { + stdio = "forward", +})).stdout) diff --git a/README.md b/README.md index a5a1a79..d27b0fe 100755 --- a/README.md +++ b/README.md @@ -85,4 +85,10 @@ A list of "branches" is visible when running the build script without providing lune run build ``` +Tests are available (if you want to contribute, edit [tests/scriptTest.txt](./tests/scriptTest.txt)): + +```bash +lune run test +``` + For more help, check out the [Rojo](https://rojo.space/docs) and [darklua](https://darklua.com/docs) documentation. diff --git a/aftman.toml b/aftman.toml index bac4749..cd52f55 100755 --- a/aftman.toml +++ b/aftman.toml @@ -8,3 +8,4 @@ darklua = "seaofvoices/darklua@0.17.3" selene = "Kampfkarren/selene@0.29.0" StyLua = "JohnnyMorganz/StyLua@2.3.1" lune = "lune-org/lune@0.10.4" +run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0" diff --git a/modules/client/wm/sandbox/environment.luau b/modules/client/wm/sandbox/environment.luau index ac49118..775820b 100644 --- a/modules/client/wm/sandbox/environment.luau +++ b/modules/client/wm/sandbox/environment.luau @@ -311,6 +311,9 @@ function Module:Init() return error(message, safeLevel or 0) end + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#gcinfo + senv.gcinfo = renv.gcinfo + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#getfenv senv.getfenv = function(...) local stack = ... @@ -318,10 +321,10 @@ function Module:Init() local stackType = type(stack) if stackType == "number" then assertArg("getfenv", 1, stack >= 0, "level must be non-negative", 1) - stack = seekSafeLevel(stack) + stack = if stack ~= 0 then seekSafeLevel(stack) else stack -- Level 0 is the current thread's environment elseif stackType == "function" then if not isStackSafe(stack) then - stack = seekSafeLevel() + stack = 0 end elseif stackType == "nil" then stack = seekSafeLevel() @@ -400,18 +403,16 @@ function Module:Init() local stackType = type(stack) if stackType == "number" then - assertArg("setfenv", 1, stack >= 0, "level must be non-negative") - if stack == 0 then - -- setfenv does nothing when stack is set to 0 - return - end + assertArg("setfenv", 1, stack > -1, "level must be non-negative") - if math.floor(stack) < 1 then + if stack == 0 then + -- Level 0 is the current thread's environment + elseif stack < 1 then -- When stack is under 1 (e.g 0.7) setfenv throws this error return error("'setfenv' cannot change environment of given object", seekSafeLevel()) + else + stack = seekSafeLevel(stack) end - - stack = seekSafeLevel(stack) elseif stackType == "function" then if not isStackSafe(stack) then return error("'setfenv' cannot change environment of given object", seekSafeLevel()) @@ -501,9 +502,6 @@ function Module:Init() return senv.delay(...) end - -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#gcinfo - senv.gcinfo = renv.gcinfo - -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#printidentity senv.printidentity = function(...) local length = select("#", ...) @@ -566,12 +564,12 @@ function Module:Init() -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#time senv.time = renv.time - -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#version - senv.version = renv.version - -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#typeof senv.typeof = renv.typeof + -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#version + senv.version = renv.version + -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#wait senv.wait = renv.wait diff --git a/modules/client/wm/sandbox/init.luau b/modules/client/wm/sandbox/init.luau index 8a302f3..82298fa 100644 --- a/modules/client/wm/sandbox/init.luau +++ b/modules/client/wm/sandbox/init.luau @@ -111,7 +111,6 @@ end function Module.claimEnvironment(environment: table, sandbox: sandbox?) sandbox = sandbox or Module.getSandbox() - Module.assertTerminated(sandbox) sandbox.Environments[environment] = true envLookup[environment] = sandbox diff --git a/modules/server/wm/sandbox/environment.luau b/modules/server/wm/sandbox/environment.luau index 547c260..f1ff973 100644 --- a/modules/server/wm/sandbox/environment.luau +++ b/modules/server/wm/sandbox/environment.luau @@ -326,6 +326,9 @@ function Module:Init() return error(message, safeLevel or 0) end + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#gcinfo + senv.gcinfo = renv.gcinfo + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#getfenv senv.getfenv = function(...) local stack = ... @@ -333,10 +336,10 @@ function Module:Init() local stackType = type(stack) if stackType == "number" then assertArg("getfenv", 1, stack >= 0, "level must be non-negative", 1) - stack = seekSafeLevel(stack) + stack = if stack ~= 0 then seekSafeLevel(stack) else stack -- Level 0 is the current thread's environment elseif stackType == "function" then if not isStackSafe(stack) then - stack = seekSafeLevel() + stack = 0 end elseif stackType == "nil" then stack = seekSafeLevel() @@ -414,18 +417,16 @@ function Module:Init() local stackType = type(stack) if stackType == "number" then - assertArg("setfenv", 1, stack >= 0, "level must be non-negative") - if stack == 0 then - -- setfenv does nothing when stack is set to 0 - return - end + assertArg("setfenv", 1, stack > -1, "level must be non-negative") - if math.floor(stack) < 1 then + if stack == 0 then + -- Level 0 is the current thread's environment + elseif stack < 1 then -- When stack is under 1 (e.g 0.7) setfenv throws this error return error("'setfenv' cannot change environment of given object", seekSafeLevel()) + else + stack = seekSafeLevel(stack) end - - stack = seekSafeLevel(stack) elseif stackType == "function" then if not isStackSafe(stack) then return error("'setfenv' cannot change environment of given object", seekSafeLevel()) @@ -529,9 +530,6 @@ function Module:Init() return senv.delay(...) end - -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#gcinfo - senv.gcinfo = renv.gcinfo - -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#printidentity senv.printidentity = function(...) local sandbox = getSandbox() @@ -600,9 +598,6 @@ function Module:Init() -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#time senv.time = renv.time - -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#version - senv.version = renv.version - -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#typeof senv.typeof = function(...) if select("#", ...) < 1 then @@ -612,6 +607,9 @@ function Module:Init() return typeofWrapped((...)) end + -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#version + senv.version = renv.version + -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#wait senv.wait = function(...) local sandbox = getSandbox() diff --git a/src/server/testRunner.server.luau b/src/server/testRunner.server.luau new file mode 100644 index 0000000..7a71aab --- /dev/null +++ b/src/server/testRunner.server.luau @@ -0,0 +1,116 @@ +local __start = os.clock() + +local Log = require("@shared/log") +Log:SetPrefix("[SB]") +Log.print("Loading...") + +do + local wm = require("@shared/wm") + wm.isWorkerManager = false + table.freeze(wm) +end + +-- Fetch assets and destroy script +local sbActor = script.Parent +local root = sbActor.Parent + +do + local thread = coroutine.running() + task.defer( + function() -- Defer has a small yield (under a frame) allowing us to delete the script (instances can't change their parent instantly after there were parented / created) + sbActor:Destroy() + script:Destroy() + script = nil + + task.spawn(thread) + end + ) + + coroutine.yield() -- Yield thread until script has been destroyed, so events dont get connected in the process (then disconnected by :Destroy()) +end + +local Assets = require("@shared/assets") +Assets:Init(root, "assets") + +Log.debug("Loading modules...") + +-- local Commands = require("@server/commands") +local WorkerManagers = require("@shared/workerManagers") +local Protection = require("@shared/protection") + +WorkerManagers:Init(root, "workerManager") +-- Network:Init() +-- Commands:Init({ require("@server/commands/default"), require("@server/commands/get") }) + +task.spawn(function() + Log.debug("Protecting TextChatService...") + + local TextChatService = game:GetService("TextChatService") + + local function protectWithExpectedStructure(instance: Instance, structure: { [string]: any }) + for name, subStructure in structure do + local child = instance:WaitForChild(name) + Protection.add(child, "write") + + protectWithExpectedStructure(child, subStructure) + end + end + + Protection.add(TextChatService, "write") + protectWithExpectedStructure(TextChatService, { + ChatWindowConfiguration = {}, + TextChannels = { + RBXGeneral = {}, + RBXSystem = {}, + }, + TextChatCommands = { + RBXHelpCommand = {}, + RBXUnmuteCommand = {}, + RBXTeamCommand = {}, + RBXClearCommand = {}, + RBXEmoteCommand = {}, + RBXWhisperCommand = {}, + RBXMuteCommand = {}, + RBXVersionCommand = {}, + RBXConsoleCommand = {}, + }, + ChatInputBarConfiguration = {}, + ChannelTabsConfiguration = {}, + BubbleChatConfiguration = {}, + }) + + Log.debug("TextChatService should be fully protected") +end) + +-- Finalize +Log.debug("Finalizing...") + +WorkerManagers:ready() + +Log.print(`Loaded in {math.round((os.clock() - __start) * 1000)}ms.`) + +local ScriptManager = require("@server/scriptManager") + +local player = nil + +do + local Players = game:GetService("Players") + + repeat + player = Players:FindFirstChildOfClass("Player") + task.wait() + until player + + local testScript = ScriptManager:CreateScript( + player, + require("@shared/scriptManager/scriptTypes").Script, + "TestRunner", + (game:WaitForChild("testRunnerSource") :: StringValue).Value + ) + testScript.Enabled = true + + Log.print("Running test script...") + testScript.Parent = workspace +end + +return nil diff --git a/tests.project.json b/tests.project.json new file mode 100644 index 0000000..168096b --- /dev/null +++ b/tests.project.json @@ -0,0 +1,108 @@ +{ + "name": "OpenSB", + "tree": { + "$className": "DataModel", + + "testRunnerSource": { + "$path": "tests/scriptTest.txt" + }, + + "ServerScriptService": { + "$properties": { + "LoadStringEnabled": true + }, + + "sbActor": { + "$className": "Actor", + + "sb": { + "$path": "out/server/testRunner.server.luau", + "$properties": { + "RunContext": "Server" + } + } + }, + + "workerManager": { + "$path": "out/server/workerManager.server.luau", + "$properties": { + "RunContext": "Server", + "Disabled": true + } + }, + + "assets": { + "$path": "assets/server", + + "hosts": { + "$className": "Folder", + + "script": { + "$path": "out/hosts/script.server.luau", + "$properties": { + "Disabled": true + } + }, + "localScript": { + "$path": "out/hosts/localScript.client.luau", + "$properties": { + "Disabled": true + } + }, + "moduleScript": { + "$path": "out/hosts/moduleScript.luau" + }, + "worker": { + "$path": "out/hosts/worker.luau" + } + } + } + }, + "Workspace": { + "$properties": { + "FilteringEnabled": true, + "SignalBehavior": "Immediate" + } + }, + "Players": { + "$properties": { + "CharacterAutoLoads": false + } + }, + "Lighting": { + "$properties": { + "GlobalShadows": true, + "Outlines": false, + "Technology": "Future" + } + }, + "StarterPlayer": { + "StarterPlayerScripts": { + "PlayerScriptsLoader": { + "$className": "LocalScript" + }, + "RbxCharacterSounds": { + "$className": "LocalScript" + }, + "PlayerModule": { + "$className": "ModuleScript" + } + } + }, + "SoundService": { + "$properties": { + "RespectFilteringEnabled": true + } + }, + "TextChatService": { + "$properties": { + "ChatVersion": "TextChatService" + } + }, + "HttpService": { + "$properties": { + "HttpEnabled": true + } + } + } +} diff --git a/tests/pluginRun.luau b/tests/pluginRun.luau new file mode 100644 index 0000000..278c66c --- /dev/null +++ b/tests/pluginRun.luau @@ -0,0 +1,20 @@ +local StudioTestService = game:GetService("StudioTestService") +local RunService = game:GetService("RunService") + +-- "ExecutePlayModeAsync: can only be called from the edit DataModel" (?) +-- This seems to fix it though +while task.wait(0.1) do + -- doesn't seem to do anything, but better be safe than sorry + if not RunService:IsEdit() then + continue + end + + if + pcall(function() + local testResult = StudioTestService:ExecutePlayModeAsync("OpenSBSuite") + print("[Plugin]", testResult) + end) + then + break + end +end diff --git a/tests/scriptTest.txt b/tests/scriptTest.txt new file mode 100644 index 0000000..9705c1f --- /dev/null +++ b/tests/scriptTest.txt @@ -0,0 +1,218 @@ +local renv = getfenv() + +local anyIssues = false +local function fail(message: string?, level: number?) + anyIssues = true + warn(debug.traceback(message, (level or 1) + 1)) +end + +local function check(value: T, message: string?, level: number?): T + if not value then + fail(message, (level or 1) + 1) + end + + return value +end + +local function checkRuns(message: string?, func: (I...) -> O..., ...: I...): (boolean, O...) + return xpcall(func, function(message) + fail(`Function threw an error: {message}`, 2) + end) +end + +local function checkError(message: string?, match: string, func: (T...) -> (), ...: T...) + local success, errMessage = pcall(func, ...) + if not check(not success, message or `Function didn't error (expected "{match}")`, 2) then + return + end + + check( + string.find(errMessage, match), + message or `Function threw an unexpected error (expected "{match}", got "{message}")` + ) +end + +local function suite(name, func: () -> ()) + xpcall(func, function(message) + fail(`Suite "{name}" threw an error: {message}`, 2) + end) +end + +local start = os.clock() + +suite("Environment", function() + local env = getfenv() + assert(type(env) == "table", "Unable to get environment") + + local script = check(rawget(env, "script"), 'Missing script global "script"') + local owner = check(rawget(env, "owner"), 'Missing script global "script"') + local _G = check(rawget(env, "_G"), 'Missing script global "_G"') + local shared = check(rawget(env, "shared"), 'Missing script global "shared"') + + check(typeof(script) == "Instance" and script:IsA("BaseScript"), [[Script global "script" isn't a BaseScript]]) + check(typeof(owner) == "Instance" and owner:IsA("Player"), [[Script global "owner" isn't a Player]]) + check(type(_G) == "table", [[Script global "_G" isn't a table]]) + check(type(shared) == "table", [[Script global "shared" isn't a table]]) + + check(rawget(env, "_VERSION") == nil, "Globals are defined as script globals") + + --[[ + Lua Global variables + https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#properties + ]] + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#_VERSION + check(_VERSION == "Luau", '_VERSION ~= "Luau"') + + --[[ + Lua Global functions + https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#functions + ]] + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#assert + checkRuns(nil, function() + assert(true, "Shouldn't error") + end) + + checkError(nil, "1234", function() + assert(false, "1234") + end) + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#error + checkError(nil, "12345", function() + error("12345") + end) + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#gcinfo + suite("gcinfo", function() + check(type(gcinfo()) == "number", "gcinfo() didn't return a number") + end) + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#getfenv + suite("getfenv", function() + assert(type(getfenv()) == "table", "getfenv() didn't return a table") + + check(getfenv() == getfenv(), "getfenv() shouldn't return different objects for the same environment") + check( + getfenv(getfenv) == getfenv(0), + "Calling getfenv() on protected objects should return the environment of the thread" + ) + checkError("negative levels shouldn't be allowed in getfenv()", "level must be non%-negative", getfenv, -1) + end) + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#getmetatable + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#ipairs + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#loadstring + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#newproxy + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#next + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#pairs + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#pcall + suite("pcall", function() + checkRuns("shouldn't propagate errors", function() + pcall(function() + error("this should be caught!") + end) + end) + end) + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#print + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#rawequal + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#rawget + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#rawlen + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#rawset + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#require + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#select + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#setfenv + suite("setfenv", function() + checkError( + "setfenv() shouldn't be able to set the environment of protected objects", + "'setfenv' cannot change environment of given object", + setfenv, + setfenv, + {} + ) + + checkError("negative levels shouldn't be allowed in setfenv()", "level must be non%-negative", setfenv, -1, {}) + + checkError( + "setfenv() should throw an error when the level is under 1, above -1, but not equal to 0", + "'setfenv' cannot change environment of given object", + setfenv, + 0.5, + {} + ) + + checkError( + "setfenv() should throw an error when the level is under 1, above -1, but not equal to 0", + "'setfenv' cannot change environment of given object", + setfenv, + -0.5, + {} + ) + + do + checkRuns(nil, function() + setfenv(0, {}) + + check(getfenv(1) ~= getfenv(0), "getfenv(1) == getfenv(0) when setfenv() has changed level 0") + end) + + setfenv(0, renv) + end + end) + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#setmetatable + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#tonumber + check(tonumber("123") == 123, "tonumber didn't return 123 for string literal '123'") + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#tostring + check(tostring(123) == "123", "tonumber didn't return string literal '123' for number 123") + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#type + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#unpack + + -- https://create.roblox.com/docs/reference/engine/globals/LuaGlobals#xpcall + + --[[ + Roblox Global variables + https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#properties + ]] + + -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#Enum + check(typeof(Enum) == "Enums", [[Global "Enum" isn't an Enums]]) + + -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#game + check(typeof(game) == "Instance", [[Global "game" isn't an Instance]]) + + -- https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#workspace + check(typeof(workspace) == "Instance", [[Global "workspace" isn't an Instance]]) + + --[[ + Roblox Global functions + https://create.roblox.com/docs/reference/engine/globals/RobloxGlobals#functions + ]] +end) + +local ms = math.round((os.clock() - start) * 1000 * 1000) / 1000 +local StudioTestService = game:GetService("StudioTestService") + +if anyIssues then + StudioTestService:EndTest(`Some issues occured, ran in {ms}ms`) +else + StudioTestService:EndTest(`All tests ran without issues in {ms}ms`) +end