diff --git a/dev.project.json b/dev.project.json index 49bb45c..7e7ca73 100644 --- a/dev.project.json +++ b/dev.project.json @@ -10,7 +10,7 @@ } } }, - "ServerScriptService": { + "TestService": { "tests": { "$path": "tests" } diff --git a/scripts/analyze.sh b/scripts/analyze.sh old mode 100644 new mode 100755 index 2ad1d75..d8f22d5 --- a/scripts/analyze.sh +++ b/scripts/analyze.sh @@ -2,10 +2,8 @@ curl -s -O https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/master/scripts/globalTypes.d.lua -cp .github/workflows/.luaurc Packages rojo sourcemap dev.project.json -o sourcemap.json -luau-lsp analyze --sourcemap=sourcemap.json --defs=globalTypes.d.lua --defs=testez.d.lua src/ +luau-lsp analyze --sourcemap=sourcemap.json --defs=globalTypes.d.lua --defs=testez.d.lua --ignore=**/_Index/** src/ -rm Packages/.luaurc -rm globalTypes.d.lua +rm globalTypes.d.lua \ No newline at end of file diff --git a/scripts/test.sh b/scripts/test.sh old mode 100644 new mode 100755 index 46b095c..0968227 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -2,3 +2,4 @@ rojo build dev.project.json -o studio-tests.rbxl run-in-roblox --place studio-tests.rbxl --script tests/init.server.lua +pkill -n RobloxStudio \ No newline at end of file diff --git a/src/createTablePassthrough.lua b/src/createTablePassthrough.lua new file mode 100644 index 0000000..cdb9258 --- /dev/null +++ b/src/createTablePassthrough.lua @@ -0,0 +1,32 @@ +--[[ + Creates a table that can be indexed and added to while also adding to a base + table. + + This is used for module globals so that a module can define variables on _G + which are maintained in a dictionary of all globals AND a dictionary of the + globals a given module has defined. + + This makes it easy to clear out the globals a modeule defines when removing + it from the cache. +]] + +type AnyTable = { [any]: any } + +local function createTablePassthrough(base: AnyTable): AnyTable + local proxy = {} + + setmetatable(proxy, { + __index = function(self, key) + local global = rawget(self, key) + return if global then global else base[key] + end, + __newindex = function(self, key, value) + base[key] = value + rawset(self, key, value) + end, + }) + + return proxy :: any +end + +return createTablePassthrough diff --git a/src/createTablePassthrough.spec.lua b/src/createTablePassthrough.spec.lua new file mode 100644 index 0000000..9cf25e0 --- /dev/null +++ b/src/createTablePassthrough.spec.lua @@ -0,0 +1,23 @@ +return function() + local createTablePassthrough = require(script.Parent.createTablePassthrough) + + it("should work for the use case of maintaining global variables", function() + local allGlobals = {} + local moduleGlobals1 = createTablePassthrough(allGlobals) + local moduleGlobals2 = createTablePassthrough(allGlobals) + + moduleGlobals1.foo = true + moduleGlobals2.bar = true + + expect(moduleGlobals1.foo).to.equal(true) + expect(moduleGlobals1.bar).to.equal(true) + expect(rawget(moduleGlobals1, "bar")).never.to.be.ok() + + expect(moduleGlobals2.bar).to.equal(true) + expect(moduleGlobals2.foo).to.equal(true) + expect(rawget(moduleGlobals2, "foo")).never.to.be.ok() + + expect(allGlobals.foo).to.equal(true) + expect(allGlobals.bar).to.equal(true) + end) +end diff --git a/src/getEnv.lua b/src/getEnv.lua index 240cee0..efb9871 100644 --- a/src/getEnv.lua +++ b/src/getEnv.lua @@ -1,6 +1,6 @@ local baseEnv = getfenv() -local function getEnv(scriptRelativeTo: LuaSourceContainer?) +local function getEnv(scriptRelativeTo: LuaSourceContainer?, globals: { [any]: any }?) local newEnv = {} setmetatable(newEnv, { @@ -13,6 +13,7 @@ local function getEnv(scriptRelativeTo: LuaSourceContainer?) end, }) + newEnv._G = globals newEnv.script = scriptRelativeTo local realDebug = debug diff --git a/src/getEnv.spec.lua b/src/getEnv.spec.lua index 9889636..2cabf25 100644 --- a/src/getEnv.spec.lua +++ b/src/getEnv.spec.lua @@ -9,4 +9,14 @@ return function() local env = getEnv(script.Parent.getEnv) expect(env.script).to.equal(script.Parent.getEnv) end) + + it("should set _G to the 'globals' argument", function() + local globals = {} + local env = getEnv(script.Parent.getEnv, globals) + + expect(env._G).to.be.ok() + expect(env._G).to.equal(globals) + -- selene: allow(global_usage) + expect(env._G).never.to.equal(_G) + end) end diff --git a/src/getRobloxTsRuntime.lua b/src/getRobloxTsRuntime.lua new file mode 100644 index 0000000..7a74d26 --- /dev/null +++ b/src/getRobloxTsRuntime.lua @@ -0,0 +1,11 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local function getRobloxTsRuntime() + local rbxtsInclude = ReplicatedStorage:FindFirstChild("rbxts_include") + if rbxtsInclude then + return rbxtsInclude:FindFirstChild("RuntimeLib") + end + return nil +end + +return getRobloxTsRuntime diff --git a/src/getRobloxTsRuntime.spec.lua b/src/getRobloxTsRuntime.spec.lua new file mode 100644 index 0000000..a3a83c7 --- /dev/null +++ b/src/getRobloxTsRuntime.spec.lua @@ -0,0 +1,26 @@ +return function() + local ReplicatedStorage = game:GetService("ReplicatedStorage") + + local getRobloxTsRuntime = require(script.Parent.getRobloxTsRuntime) + + it("should retrieve the roblox-ts runtime library", function() + local includes = Instance.new("Folder") + includes.Name = "rbxts_include" + includes.Parent = ReplicatedStorage + + local mockRuntime = Instance.new("ModuleScript") + mockRuntime.Name = "RuntimeLib" + mockRuntime.Parent = includes + + local runtime = getRobloxTsRuntime() + + includes:Destroy() + + expect(runtime == mockRuntime).to.equal(true) + end) + + it("should return nil if the runtime can't be found", function() + local runtime = getRobloxTsRuntime() + expect(runtime).never.to.be.ok() + end) +end diff --git a/src/init.lua b/src/init.lua index 5297a57..8923fb8 100644 --- a/src/init.lua +++ b/src/init.lua @@ -3,6 +3,12 @@ local GoodSignal = require(script.Parent.GoodSignal) local bind = require(script.bind) local getCallerPath = require(script.getCallerPath) local getEnv = require(script.getEnv) +local createTablePassthrough = require(script.createTablePassthrough) +local getRobloxTsRuntime = require(script.getRobloxTsRuntime) +local types = require(script.types) + +type ModuleConsumers = types.ModuleConsumers +type ModuleGlobals = types.ModuleGlobals --[=[ ModuleScript loader that bypasses Roblox's require cache. @@ -22,7 +28,8 @@ export type CachedModule = { module: ModuleScript, isLoaded: boolean, result: any, - consumers: { string }, + consumers: ModuleConsumers, + globals: ModuleGlobals, } --[=[ @@ -35,6 +42,7 @@ function ModuleLoader.new() self._loadstring = loadstring self._debugInfo = debug.info self._janitors = {} + self._globals = {} --[=[ Fired when any ModuleScript required through this class has its ancestry @@ -93,19 +101,6 @@ function ModuleLoader:_getSource(module: ModuleScript): any? return if success then result else nil end -function ModuleLoader:_clearConsumerFromCache(moduleFullName: string) - local cachedModule: CachedModule = self._cache[moduleFullName] - - if cachedModule then - for _, consumer in ipairs(cachedModule.consumers) do - self._cache[consumer] = nil - self:_clearConsumerFromCache(consumer) - end - - self._cache[moduleFullName] = nil - end -end - --[=[ Tracks the changes to a required module's ancestry and `Source`. @@ -122,13 +117,12 @@ function ModuleLoader:_trackChanges(module: ModuleScript) janitor:Cleanup() janitor:Add(module.AncestryChanged:Connect(function() - self.loadedModuleChanged:Fire(module) + self:clearModule(module) end)) janitor:Add(module.Changed:Connect(function(prop: string) if prop == "Source" then - self:_clearConsumerFromCache(module:GetFullName()) - self.loadedModuleChanged:Fire(module) + self:clearModule(module) end end)) @@ -156,6 +150,7 @@ function ModuleLoader:cache(module: ModuleScript, result: any) result = result, isLoaded = true, consumers = {}, + globals = createTablePassthrough(self._globals), } self._cache[module:GetFullName()] = cachedModule @@ -178,10 +173,7 @@ function ModuleLoader:require(module: ModuleScript) local callerPath = getCallerPath() if cachedModule then - if self._cache[callerPath] then - table.insert(cachedModule.consumers, callerPath) - end - + cachedModule.consumers[callerPath] = true return self:_loadCachedModule(module) end @@ -192,17 +184,20 @@ function ModuleLoader:require(module: ModuleScript) error(("Could not parse %s: %s"):format(module:GetFullName(), parseError)) end + local globals = createTablePassthrough(self._globals) + local newCachedModule: CachedModule = { module = module, result = nil, isLoaded = false, consumers = { - if self._cache[callerPath] then callerPath else nil, + [callerPath] = true, }, + globals = globals, } self._cache[module:GetFullName()] = newCachedModule - local env = getEnv(module) + local env = getEnv(module, globals) env.require = bind(self, self.require) setfenv(moduleFn, env) @@ -220,6 +215,69 @@ function ModuleLoader:require(module: ModuleScript) return self:_loadCachedModule(module) end +function ModuleLoader:_getConsumers(module: ModuleScript): { ModuleScript } + local function getConsumersRecursively(cachedModule: CachedModule, found: { [ModuleScript]: true }) + for consumer in cachedModule.consumers do + local cachedConsumer = self._cache[consumer] + + if cachedConsumer then + if not found[cachedConsumer.module] then + found[cachedConsumer.module] = true + getConsumersRecursively(cachedConsumer, found) + end + end + end + end + + local cachedModule: CachedModule = self._cache[module:GetFullName()] + local found = {} + + getConsumersRecursively(cachedModule, found) + + local consumers = {} + for consumer in found do + table.insert(consumers, consumer) + end + + return consumers +end + +function ModuleLoader:clearModule(moduleToClear: ModuleScript) + if not self._cache[moduleToClear:GetFullName()] then + return + end + + local consumers = self:_getConsumers(moduleToClear) + local modulesToClear = { moduleToClear, table.unpack(consumers) } + + local index = table.find(modulesToClear, getRobloxTsRuntime()) + if index then + table.remove(modulesToClear, index) + end + + for _, module in modulesToClear do + local fullName = module:GetFullName() + + local cachedModule = self._cache[fullName] + + if cachedModule then + self._cache[fullName] = nil + + for key in cachedModule.globals do + self._globals[key] = nil + end + + local janitor = self._janitors[fullName] + janitor:Cleanup() + end + end + + for _, module in modulesToClear do + print("loadedModuleChanged", module:GetFullName()) + self.loadedModuleChanged:Fire(module) + end +end + --[=[ Clears out the internal cache. @@ -243,6 +301,7 @@ end ]=] function ModuleLoader:clear() self._cache = {} + self._globals = {} for _, janitor in self._janitors do janitor:Cleanup() diff --git a/src/init.spec.lua b/src/init.spec.lua index 8abb7e3..8199d22 100644 --- a/src/init.spec.lua +++ b/src/init.spec.lua @@ -1,33 +1,59 @@ return function() - local Mock = require(script.Parent.Parent.Mock) + local ReplicatedStorage = game:GetService("ReplicatedStorage") + local ModuleLoader = require(script.Parent) - local mockLoadstring = Mock.new() - local loader: ModuleLoader.Class - local mockModuleSource = {} + local function countDict(dict: { [string]: any }) + local count = 0 + for _ in pairs(dict) do + count += 1 + end + return count + end - beforeEach(function() - mockLoadstring:mockImplementation(function() - return function() - return true + type ModuleTestTree = { + [string]: string | ModuleTestTree, + } + local testNumber = 0 + local function createModuleTest(tree: ModuleTestTree, parent: Instance?) + testNumber += 1 + + local root = Instance.new("Folder") + root.Name = "ModuleTest" .. testNumber + + parent = if parent then parent else root + + for name, sourceOrDescendants in tree do + if typeof(sourceOrDescendants) == "table" then + createModuleTest(sourceOrDescendants, parent) + else + local module = Instance.new("ModuleScript") + module.Name = name + module.Source = sourceOrDescendants + module.Parent = parent end - end) + end + + root.Parent = game + + return root + end + local mockModuleSource = {} + local loader: ModuleLoader.Class + local tree + + beforeEach(function() loader = ModuleLoader.new() - loader._loadstring = mockLoadstring end) afterEach(function() - mockLoadstring:reset() - end) + loader:clear() - local function countDict(dict: { [string]: any }) - local count = 0 - for _ in pairs(dict) do - count += 1 + if tree then + tree:Destroy() end - return count - end + end) describe("_getSource", function() -- This test doesn't supply much value. Essentially, the "Source" @@ -83,7 +109,7 @@ return function() -- Parent the ModuleScript somewhere in the DataModel so we can -- listen for AncestryChanged. - mockModuleInstance.Parent = script + mockModuleInstance.Parent = game loader.loadedModuleChanged:Connect(function(other: ModuleScript) if other == mockModuleInstance then @@ -101,23 +127,9 @@ return function() end) it("should fire when a required module has its Source property change", function() - local wasFired = false - - local mockModuleInstance = Mock.new() - - -- This method needs to be stubbed out to suppress an error - mockModuleInstance.GetFullName:mockImplementation(function() - return "Path.To.ModuleScript" - end) - - -- Need to stub out this event to suppress an error - mockModuleInstance.AncestryChanged = Instance.new("BindableEvent").Event - - -- Setup mock Changed event since we can't modify the Source - -- property ourselves - local sourceChanged = Instance.new("BindableEvent") - mockModuleInstance.Changed = sourceChanged.Event + local mockModuleInstance = Instance.new("ModuleScript") + local wasFired = false loader.loadedModuleChanged:Connect(function(other: ModuleScript) if other == mockModuleInstance then wasFired = true @@ -127,11 +139,41 @@ return function() -- Require the module so that events get setup loader:require(mockModuleInstance) - -- Trigger the mocked Changed event - sourceChanged:Fire("Source") + mockModuleInstance.Source = "Something different" expect(wasFired).to.equal(true) end) + + it("should fire for every consumer up the chain", function() + tree = createModuleTest({ + ModuleA = [[ + return "ModuleA" + ]], + ModuleB = [[ + require(script.Parent.ModuleA) + return "ModuleB" + ]], + ModuleC = [[ + require(script.Parent.ModuleB) + return "ModuleC" + ]], + }) + + local count = 0 + loader.loadedModuleChanged:Connect(function(module) + for _, child in tree:GetChildren() do + if module == child then + count += 1 + end + end + end) + + loader:require(tree.ModuleC) + + tree.ModuleA.Source = "Changed" + + expect(count).to.equal(3) + end) end) describe("cache", function() @@ -148,120 +190,449 @@ return function() end) describe("require", function() - it("should use loadstring to load the module", function() + it("should add the module to the cache", function() local mockModuleInstance = Instance.new("ModuleScript") loader:require(mockModuleInstance) - expect(#mockLoadstring.mock.calls).to.equal(1) + expect(loader._cache[mockModuleInstance:GetFullName()]).to.be.ok() end) - it("should add the module to the cache", function() - local mockModuleInstance = Instance.new("ModuleScript") + it("should return cached results", function() + tree = createModuleTest({ + -- We return a table since it can act as a unique symbol. So if + -- both consumers are getting the same table we can perform an + -- equality check + SharedModule = [[ + local module = {} + return module + ]], + Consumer1 = [[ + local sharedModule = require(script.Parent.SharedModule) + return sharedModule + ]], + Consumer2 = [[ + local sharedModule = require(script.Parent.SharedModule) + return sharedModule + ]], + }) + + local sharedModuleFromConsumer1 = loader:require(tree.Consumer1) + local sharedModuleFromConsumer2 = loader:require(tree.Consumer2) + + expect(sharedModuleFromConsumer1).to.equal(sharedModuleFromConsumer2) + end) - loader:require(mockModuleInstance) - expect(loader._cache[mockModuleInstance:GetFullName()]).to.be.ok() + it("should add the calling script as a consumer", function() + tree = createModuleTest({ + SharedModule = [[ + local module = {} + return module + ]], + Consumer = [[ + local sharedModule = require(script.Parent.SharedModule) + return sharedModule + ]], + }) + + loader:require(tree.Consumer) + + local cachedModule = loader._cache[tree.SharedModule:GetFullName()] + + expect(cachedModule).to.be.ok() + expect(cachedModule.consumers[tree.Consumer:GetFullName()]).to.be.ok() end) - end) - describe("clear", function() - it("should remove all modules from the cache", function() - local mockModuleInstance = Instance.new("ModuleScript") + it("should update consumers when requiring a cached module from a different script", function() + tree = createModuleTest({ + SharedModule = [[ + local module = {} + return module + ]], + Consumer1 = [[ + local sharedModule = require(script.Parent.SharedModule) + return sharedModule + ]], + Consumer2 = [[ + local sharedModule = require(script.Parent.SharedModule) + return sharedModule + ]], + }) + + loader:require(tree.Consumer1) + + local cachedModule = loader._cache[tree.SharedModule:GetFullName()] + + expect(cachedModule.consumers[tree.Consumer1:GetFullName()]).to.be.ok() + expect(cachedModule.consumers[tree.Consumer2:GetFullName()]).never.to.be.ok() + + loader:require(tree.Consumer2) + + expect(cachedModule.consumers[tree.Consumer1:GetFullName()]).to.be.ok() + expect(cachedModule.consumers[tree.Consumer2:GetFullName()]).to.be.ok() + end) - loader:cache(mockModuleInstance, mockModuleSource) + it("should keep track of _G between modules", function() + tree = createModuleTest({ + WriteGlobal = [[ + _G.foo = true + return nil + ]], + ReadGlobal = [[ + return _G.foo + ]], + }) - expect(countDict(loader._cache)).to.equal(1) + loader:require(tree.WriteGlobal) + + expect(loader._globals.foo).to.equal(true) + + local result = loader:require(tree.ReadGlobal) + + expect(result).to.equal(true) + end) + + it("should keep track of _G in nested requires", function() + tree = createModuleTest({ + DefineGlobal = [[ + _G.foo = true + return nil + ]], + UseGlobal = [[ + require(script.Parent.DefineGlobal) + return _G.foo + ]], + }) + + local result = loader:require(tree.UseGlobal) + + expect(result).to.equal(true) loader:clear() - expect(countDict(loader._cache)).to.equal(0) + expect(loader._globals.foo).never.to.be.ok() + end) + + it("should add globals on _G to the cachedModule's globals", function() + tree = createModuleTest({ + DefineGlobal = [[ + _G.foo = true + return nil + ]], + }) + + loader:require(tree.DefineGlobal) + + local cachedModule = loader._cache[tree.DefineGlobal:GetFullName()] + expect(cachedModule.globals.foo).to.equal(true) end) end) - -- For these tests to work, TestEZ must be run from a plugin context so that - -- loadstring works, along with assigning to the `Source` property of - -- modules - describe("consumers", function() - local modules = Instance.new("Folder") :: Folder & { - ModuleA: ModuleScript, - ModuleB: ModuleScript, - ModuleC: ModuleScript, - } + describe("clearModule", function() + it("should clear a module from the cache", function() + tree = createModuleTest({ + Module = [[ + return "Module" + ]], + }) - beforeEach(function() - local moduleA = Instance.new("ModuleScript") - moduleA.Name = "ModuleA" - moduleA.Source = [[ - require(script.Parent.ModuleB) + loader:require(tree.Module) - return "ModuleA" - ]] - moduleA.Parent = modules + expect(loader._cache[tree.Module:GetFullName()]).to.be.ok() - local moduleB = Instance.new("ModuleScript") - moduleB.Name = "ModuleB" - moduleB.Source = [[ - return "ModuleB" - ]] - moduleB.Parent = modules + loader:clearModule(tree.Module) - local moduleC = Instance.new("ModuleScript") - moduleC.Name = "ModuleC" - moduleC.Source = [[ - return "ModuleC" - ]] - moduleC.Parent = modules + expect(loader._cache[tree.Module:GetFullName()]).never.to.be.ok() + end) + + it("should clear all consumers of a module from the cache", function() + tree = createModuleTest({ + SharedModule = [[ + local module = {} + return module + ]], + Consumer1 = [[ + local sharedModule = require(script.Parent.SharedModule) + return sharedModule + ]], + Consumer2 = [[ + local sharedModule = require(script.Parent.SharedModule) + return sharedModule + ]], + }) + + loader:require(tree.Consumer1) + loader:require(tree.Consumer2) + + expect(loader._cache[tree.Consumer1:GetFullName()]).to.be.ok() + expect(loader._cache[tree.Consumer2:GetFullName()]).to.be.ok() + expect(loader._cache[tree.SharedModule:GetFullName()]).to.be.ok() + + loader:clearModule(tree.SharedModule) + + expect(loader._cache[tree.Consumer1:GetFullName()]).never.to.be.ok() + expect(loader._cache[tree.Consumer2:GetFullName()]).never.to.be.ok() + expect(loader._cache[tree.SharedModule:GetFullName()]).never.to.be.ok() + end) - modules.Parent = game + it("should only clear modules in the consumer chain", function() + tree = createModuleTest({ + Module = [[ + return nil + ]], + Consumer = [[ + require(script.Parent.Module) + return nil + ]], + Independent = [[ + return nil + ]], + }) - loader._loadstring = loadstring + loader:require(tree.Consumer) + loader:require(tree.Independent) + + expect(countDict(loader._cache)).to.equal(3) + + loader:clearModule(tree.Module) + + expect(countDict(loader._cache)).to.equal(1) + expect(loader._cache[tree.Independent:GetFullName()]).to.be.ok() end) - afterEach(function() - modules:ClearAllChildren() + it("should clear all globals that a module supplied", function() + tree = createModuleTest({ + DefineGlobalFoo = [[ + _G.foo = true + return nil + ]], + DefineGlobalBar = [[ + _G.bar = false + return nil + ]], + }) + + loader:require(tree.DefineGlobalFoo) + loader:require(tree.DefineGlobalBar) + + loader:clearModule(tree.DefineGlobalBar) + + expect(loader._globals.foo).to.be.ok() + expect(loader._globals.bar).never.to.be.ok() + end) + + it("should fire loadedModuleChanged when clearing a module", function() + tree = createModuleTest({ + Module = [[ + return nil + ]], + Consumer = [[ + require(script.Parent.Module) + return nil + ]], + }) + + local wasFired = false + + loader.loadedModuleChanged:Connect(function() + wasFired = true + end) + + loader:require(tree.Consumer) + loader:clearModule(tree.Consumer) + + expect(wasFired).to.equal(true) end) - it("should keep track of the consumers for a module", function() - loader:require(modules.ModuleA) + it("should fire loadedModuleChanged for every module up the chain", function() + tree = createModuleTest({ + Module3 = [[ + return {} + ]], + Module2 = [[ + require(script.Parent.Module3) + return {} + ]], + Module1 = [[ + require(script.Parent.Module2) + return {} + ]], + Consumer = [[ + require(script.Parent.Module1) + return nil + ]], + }) + + local count = 0 + + loader.loadedModuleChanged:Connect(function() + count += 1 + end) - expect(loader._cache[modules.ModuleA:GetFullName()]).to.be.ok() + loader:require(tree.Consumer) + loader:clearModule(tree.Module3) - local cachedModuleB = loader._cache[modules.ModuleB:GetFullName()] + expect(count).to.equal(4) + end) - expect(cachedModuleB).to.be.ok() - expect(#cachedModuleB.consumers).to.equal(1) - expect(cachedModuleB.consumers[1]).to.equal(modules.ModuleA:GetFullName()) + it("should not fire loadedModuleChanged for a module that hasn't been required", function() + local wasFired = false + + loader.loadedModuleChanged:Connect(function() + wasFired = true + end) + + -- Do nothing if the module hasn't been cached + local module = Instance.new("ModuleScript") + loader:clearModule(module) + expect(wasFired).to.equal(false) + end) + end) + + describe("clear", function() + it("should remove all modules from the cache", function() + local mockModuleInstance = Instance.new("ModuleScript") + + loader:cache(mockModuleInstance, mockModuleSource) + + expect(countDict(loader._cache)).to.equal(1) + + loader:clear() + + expect(countDict(loader._cache)).to.equal(0) + end) + + it("should reset globals", function() + local globals = loader._globals + + loader:clear() + + expect(loader._globals).never.to.equal(globals) + end) + end) + + describe("consumers", function() + beforeEach(function() + tree = createModuleTest({ + ModuleA = [[ + require(script.Parent.ModuleB) + + return "ModuleA" + ]], + ModuleB = [[ + return "ModuleB" + ]], + + ModuleC = [[ + return "ModuleC" + ]], + }) end) it("should remove all consumers of a changed module from the cache", function() - loader:require(modules.ModuleA) + loader:require(tree.ModuleA) local hasItems = next(loader._cache) ~= nil expect(hasItems).to.equal(true) - task.defer(function() - modules.ModuleB.Source = 'return "ModuleB Reloaded"' - end) - loader.loadedModuleChanged:Wait() + tree.ModuleB.Source = 'return "ModuleB Reloaded"' + task.wait() hasItems = next(loader._cache) ~= nil expect(hasItems).to.equal(false) end) it("should not interfere with other cached modules", function() - loader:require(modules.ModuleA) - loader:require(modules.ModuleC) + loader:require(tree.ModuleA) + loader:require(tree.ModuleC) local hasItems = next(loader._cache) ~= nil expect(hasItems).to.equal(true) - task.defer(function() - modules.ModuleB.Source = 'return "ModuleB Reloaded"' - end) - loader.loadedModuleChanged:Wait() + tree.ModuleB.Source = 'return "ModuleB Reloaded"' + task.wait() + + expect(loader._cache[tree.ModuleA:GetFullName()]).never.to.be.ok() + expect(loader._cache[tree.ModuleB:GetFullName()]).never.to.be.ok() + expect(loader._cache[tree.ModuleC:GetFullName()]).to.be.ok() + end) + end) + + describe("roblox-ts", function() + local rbxtsInclude + local mockRuntime + + beforeEach(function() + rbxtsInclude = Instance.new("Folder") + rbxtsInclude.Name = "rbxts_include" + + mockRuntime = Instance.new("ModuleScript") + mockRuntime.Name = "RuntimeLib" + mockRuntime.Source = [[ + local function import(...) + return require(...) + end + return { + import = import + } + ]] + mockRuntime.Parent = rbxtsInclude + + rbxtsInclude.Parent = ReplicatedStorage + end) + + afterEach(function() + loader:clear() + rbxtsInclude:Destroy() + end) + + it("clearModule() should never clear the roblox-ts runtime from the cache", function() + -- This example isn't quite how a roblox-ts project would be setup + -- in practice since the require's for `Shared` would be using + -- `TS.import`, but it should be close enough for our test case + tree = createModuleTest({ + Shared = [[ + local TS = require(game:GetService("ReplicatedStorage").rbxts_include.RuntimeLib) + return {} + ]], + Module1 = [[ + local TS = require(game:GetService("ReplicatedStorage").rbxts_include.RuntimeLib) + local Shared = TS.import(script.Parent.Shared) + return nil + ]], + Module2 = [[ + local TS = require(game:GetService("ReplicatedStorage").rbxts_include.RuntimeLib) + local Shared = TS.import(script.Parent.Shared) + return nil + ]], + Root = [[ + local TS = require(game:GetService("ReplicatedStorage").rbxts_include.RuntimeLib) + local Module1 = TS.import(script.Parent.Module1) + local Module2 = TS.import(script.Parent.Module2) + ]], + }) + + loader:require(tree.Root) + loader:clearModule(tree.Shared) + + expect(loader._cache[mockRuntime:GetFullName()]).to.be.ok() + expect(loader._cache[tree.Shared:GetFullName()]).never.to.be.ok() + expect(loader._cache[tree.Module1:GetFullName()]).never.to.be.ok() + expect(loader._cache[tree.Module2:GetFullName()]).never.to.be.ok() + expect(loader._cache[tree.Root:GetFullName()]).never.to.be.ok() + end) + + it("clear() should clear the roblox-ts runtime when calling", function() + tree = createModuleTest({ + Module = [[ + local TS = require(game:GetService("ReplicatedStorage").rbxts_include.RuntimeLib) + ]], + }) + + loader:require(tree.Module) + loader:clear() - expect(loader._cache[modules.ModuleA:GetFullName()]).never.to.be.ok() - expect(loader._cache[modules.ModuleB:GetFullName()]).never.to.be.ok() - expect(loader._cache[modules.ModuleC:GetFullName()]).to.be.ok() + expect(loader._cache[mockRuntime:GetFullName()]).never.to.be.ok() + expect(loader._cache[tree.Module:GetFullName()]).never.to.be.ok() end) end) end diff --git a/src/types.lua b/src/types.lua new file mode 100644 index 0000000..7e9dc0f --- /dev/null +++ b/src/types.lua @@ -0,0 +1,13 @@ +export type ModuleConsumers = { + [string]: boolean, +} + +-- Each module gets its own global table that it can modify via _G. This makes +-- it easy to clear out a module and the globals it defines without impacting +-- other modules. A module's function environment has all globals merged +-- together on _G +export type ModuleGlobals = { + [any]: any, +} + +return {} diff --git a/tests/init.server.lua b/tests/init.server.lua index 20d9c73..9ec4b7f 100644 --- a/tests/init.server.lua +++ b/tests/init.server.lua @@ -4,7 +4,7 @@ local TestEZ = require(ReplicatedStorage.Packages.TestEZ) local results = TestEZ.TestBootstrap:run({ ReplicatedStorage.Packages.ModuleLoader, -}) +}, TestEZ.Reporters.TextReporterQuiet) if results.failureCount > 0 then print("❌ Test run failed") diff --git a/wally.toml b/wally.toml index 5b272cb..956e385 100644 --- a/wally.toml +++ b/wally.toml @@ -19,4 +19,3 @@ include = [ GoodSignal = "stravant/goodsignal@0.1.1" Janitor = "howmanysmall/janitor@1.13.15" TestEZ = "roblox/testez@0.4.1" -Mock = "vocksel/mock@0.1.1"