From 4b568356c83afc8232222cd4ffc3366d029c424a Mon Sep 17 00:00:00 2001 From: EternityDev Date: Thu, 14 Mar 2024 11:58:08 +0700 Subject: [PATCH] v1.0.8 --- TestEZ/Context.lua | 26 +++ TestEZ/Expectation.lua | 311 +++++++++++++++++++++++++ TestEZ/ExpectationContext.lua | 38 +++ TestEZ/LifecycleHooks.lua | 89 +++++++ TestEZ/Reporters/TeamCityReporter.lua | 102 ++++++++ TestEZ/Reporters/TextReporter.lua | 106 +++++++++ TestEZ/Reporters/TextReporterQuiet.lua | 97 ++++++++ TestEZ/TestBootstrap.lua | 147 ++++++++++++ TestEZ/TestEnum.lua | 28 +++ TestEZ/TestPlan.lua | 304 ++++++++++++++++++++++++ TestEZ/TestPlanner.lua | 40 ++++ TestEZ/TestResults.lua | 112 +++++++++ TestEZ/TestRunner.lua | 188 +++++++++++++++ TestEZ/TestSession.lua | 243 +++++++++++++++++++ TestEZ/init.lua | 42 ++++ Warp.rbxm | Bin 11933 -> 12089 bytes runTests.server.luau | 3 + src/Index/Client/Index.luau | 20 +- src/Index/Server/Index.luau | 13 +- src/Index/Server/ServerProcess.luau | 1 - src/Index/Signal/init.luau | 2 +- src/Index/init.luau | 1 + src/init.luau | 3 +- test.project.json | 30 +++ test/init.spec.luau | 166 +++++++++++++ wally.toml | 2 +- 26 files changed, 2101 insertions(+), 13 deletions(-) create mode 100644 TestEZ/Context.lua create mode 100644 TestEZ/Expectation.lua create mode 100644 TestEZ/ExpectationContext.lua create mode 100644 TestEZ/LifecycleHooks.lua create mode 100644 TestEZ/Reporters/TeamCityReporter.lua create mode 100644 TestEZ/Reporters/TextReporter.lua create mode 100644 TestEZ/Reporters/TextReporterQuiet.lua create mode 100644 TestEZ/TestBootstrap.lua create mode 100644 TestEZ/TestEnum.lua create mode 100644 TestEZ/TestPlan.lua create mode 100644 TestEZ/TestPlanner.lua create mode 100644 TestEZ/TestResults.lua create mode 100644 TestEZ/TestRunner.lua create mode 100644 TestEZ/TestSession.lua create mode 100644 TestEZ/init.lua create mode 100644 runTests.server.luau create mode 100644 test.project.json create mode 100644 test/init.spec.luau diff --git a/TestEZ/Context.lua b/TestEZ/Context.lua new file mode 100644 index 0000000..efd4993 --- /dev/null +++ b/TestEZ/Context.lua @@ -0,0 +1,26 @@ +--[[ + The Context object implements a write-once key-value store. It also allows + for a new Context object to inherit the entries from an existing one. +]] +local Context = {} + +function Context.new(parent) + local meta = {} + local index = {} + meta.__index = index + + if parent then + for key, value in pairs(getmetatable(parent).__index) do + index[key] = value + end + end + + function meta.__newindex(_obj, key, value) + assert(index[key] == nil, string.format("Cannot reassign %s in context", tostring(key))) + index[key] = value + end + + return setmetatable({}, meta) +end + +return Context diff --git a/TestEZ/Expectation.lua b/TestEZ/Expectation.lua new file mode 100644 index 0000000..96dc2c7 --- /dev/null +++ b/TestEZ/Expectation.lua @@ -0,0 +1,311 @@ +--[[ + Allows creation of expectation statements designed for behavior-driven + testing (BDD). See Chai (JS) or RSpec (Ruby) for examples of other BDD + frameworks. + + The Expectation class is exposed to tests as a function called `expect`: + + expect(5).to.equal(5) + expect(foo()).to.be.ok() + + Expectations can be negated using .never: + + expect(true).never.to.equal(false) + + Expectations throw errors when their conditions are not met. +]] + +local Expectation = {} + +--[[ + These keys don't do anything except make expectations read more cleanly +]] +local SELF_KEYS = { + to = true, + be = true, + been = true, + have = true, + was = true, + at = true, +} + +--[[ + These keys invert the condition expressed by the Expectation. +]] +local NEGATION_KEYS = { + never = true, +} + +--[[ + Extension of Lua's 'assert' that lets you specify an error level. +]] +local function assertLevel(condition, message, level) + message = message or "Assertion failed!" + level = level or 1 + + if not condition then + error(message, level + 1) + end +end + +--[[ + Returns a version of the given method that can be called with either . or : +]] +local function bindSelf(self, method) + return function(firstArg, ...) + if firstArg == self then + return method(self, ...) + else + return method(self, firstArg, ...) + end + end +end + +local function formatMessage(result, trueMessage, falseMessage) + if result then + return trueMessage + else + return falseMessage + end +end + +--[[ + Create a new expectation +]] +function Expectation.new(value) + local self = { + value = value, + successCondition = true, + condition = false, + matchers = {}, + _boundMatchers = {}, + } + + setmetatable(self, Expectation) + + self.a = bindSelf(self, self.a) + self.an = self.a + self.ok = bindSelf(self, self.ok) + self.equal = bindSelf(self, self.equal) + self.throw = bindSelf(self, self.throw) + self.near = bindSelf(self, self.near) + + return self +end + +function Expectation.checkMatcherNameCollisions(name) + if SELF_KEYS[name] or NEGATION_KEYS[name] or Expectation[name] then + return false + end + + return true +end + +function Expectation:extend(matchers) + self.matchers = matchers or {} + + for name, implementation in pairs(self.matchers) do + self._boundMatchers[name] = bindSelf(self, function(_self, ...) + local result = implementation(self.value, ...) + local pass = result.pass == self.successCondition + + assertLevel(pass, result.message, 3) + self:_resetModifiers() + return self + end) + end + + return self +end + +function Expectation.__index(self, key) + -- Keys that don't do anything except improve readability + if SELF_KEYS[key] then + return self + end + + -- Invert your assertion + if NEGATION_KEYS[key] then + local newExpectation = Expectation.new(self.value):extend(self.matchers) + newExpectation.successCondition = not self.successCondition + + return newExpectation + end + + if self._boundMatchers[key] then + return self._boundMatchers[key] + end + + -- Fall back to methods provided by Expectation + return Expectation[key] +end + +--[[ + Called by expectation terminators to reset modifiers in a statement. + + This makes chains like: + + expect(5) + .never.to.equal(6) + .to.equal(5) + + Work as expected. +]] +function Expectation:_resetModifiers() + self.successCondition = true +end + +--[[ + Assert that the expectation value is the given type. + + expect(5).to.be.a("number") +]] +function Expectation:a(typeName) + local result = (type(self.value) == typeName) == self.successCondition + + local message = formatMessage(self.successCondition, + ("Expected value of type %q, got value %q of type %s"):format( + typeName, + tostring(self.value), + type(self.value) + ), + ("Expected value not of type %q, got value %q of type %s"):format( + typeName, + tostring(self.value), + type(self.value) + ) + ) + + assertLevel(result, message, 3) + self:_resetModifiers() + + return self +end + +-- Make alias public on class +Expectation.an = Expectation.a + +--[[ + Assert that our expectation value is truthy +]] +function Expectation:ok() + local result = (self.value ~= nil) == self.successCondition + + local message = formatMessage(self.successCondition, + ("Expected value %q to be non-nil"):format( + tostring(self.value) + ), + ("Expected value %q to be nil"):format( + tostring(self.value) + ) + ) + + assertLevel(result, message, 3) + self:_resetModifiers() + + return self +end + +--[[ + Assert that our expectation value is equal to another value +]] +function Expectation:equal(otherValue) + local result = (self.value == otherValue) == self.successCondition + + local message = formatMessage(self.successCondition, + ("Expected value %q (%s), got %q (%s) instead"):format( + tostring(otherValue), + type(otherValue), + tostring(self.value), + type(self.value) + ), + ("Expected anything but value %q (%s)"):format( + tostring(otherValue), + type(otherValue) + ) + ) + + assertLevel(result, message, 3) + self:_resetModifiers() + + return self +end + +--[[ + Assert that our expectation value is equal to another value within some + inclusive limit. +]] +function Expectation:near(otherValue, limit) + assert(type(self.value) == "number", "Expectation value must be a number to use 'near'") + assert(type(otherValue) == "number", "otherValue must be a number") + assert(type(limit) == "number" or limit == nil, "limit must be a number or nil") + + limit = limit or 1e-7 + + local result = (math.abs(self.value - otherValue) <= limit) == self.successCondition + + local message = formatMessage(self.successCondition, + ("Expected value to be near %f (within %f) but got %f instead"):format( + otherValue, + limit, + self.value + ), + ("Expected value to not be near %f (within %f) but got %f instead"):format( + otherValue, + limit, + self.value + ) + ) + + assertLevel(result, message, 3) + self:_resetModifiers() + + return self +end + +--[[ + Assert that our functoid expectation value throws an error when called. + An optional error message can be passed to assert that the error message + contains the given value. +]] +function Expectation:throw(messageSubstring) + local ok, err = pcall(self.value) + local result = ok ~= self.successCondition + + if messageSubstring and not ok then + if self.successCondition then + result = err:find(messageSubstring, 1, true) ~= nil + else + result = err:find(messageSubstring, 1, true) == nil + end + end + + local message + + if messageSubstring then + message = formatMessage(self.successCondition, + ("Expected function to throw an error containing %q, but it %s"):format( + messageSubstring, + err and ("threw: %s"):format(err) or "did not throw." + ), + ("Expected function to never throw an error containing %q, but it threw: %s"):format( + messageSubstring, + tostring(err) + ) + ) + else + message = formatMessage(self.successCondition, + "Expected function to throw an error, but it did not throw.", + ("Expected function to succeed, but it threw an error: %s"):format( + tostring(err) + ) + ) + end + + assertLevel(result, message, 3) + self:_resetModifiers() + + return self +end + +return Expectation diff --git a/TestEZ/ExpectationContext.lua b/TestEZ/ExpectationContext.lua new file mode 100644 index 0000000..b55f53c --- /dev/null +++ b/TestEZ/ExpectationContext.lua @@ -0,0 +1,38 @@ +local Expectation = require(script.Parent.Expectation) +local checkMatcherNameCollisions = Expectation.checkMatcherNameCollisions + +local function copy(t) + local result = {} + + for key, value in pairs(t) do + result[key] = value + end + + return result +end + +local ExpectationContext = {} +ExpectationContext.__index = ExpectationContext + +function ExpectationContext.new(parent) + local self = { + _extensions = parent and copy(parent._extensions) or {}, + } + + return setmetatable(self, ExpectationContext) +end + +function ExpectationContext:startExpectationChain(...) + return Expectation.new(...):extend(self._extensions) +end + +function ExpectationContext:extend(config) + for key, value in pairs(config) do + assert(self._extensions[key] == nil, string.format("Cannot reassign %q in expect.extend", key)) + assert(checkMatcherNameCollisions(key), string.format("Cannot overwrite matcher %q; it already exists", key)) + + self._extensions[key] = value + end +end + +return ExpectationContext diff --git a/TestEZ/LifecycleHooks.lua b/TestEZ/LifecycleHooks.lua new file mode 100644 index 0000000..c60b497 --- /dev/null +++ b/TestEZ/LifecycleHooks.lua @@ -0,0 +1,89 @@ +local TestEnum = require(script.Parent.TestEnum) + +local LifecycleHooks = {} +LifecycleHooks.__index = LifecycleHooks + +function LifecycleHooks.new() + local self = { + _stack = {}, + } + return setmetatable(self, LifecycleHooks) +end + +--[[ + Returns an array of `beforeEach` hooks in FIFO order +]] +function LifecycleHooks:getBeforeEachHooks() + local key = TestEnum.NodeType.BeforeEach + local hooks = {} + + for _, level in ipairs(self._stack) do + for _, hook in ipairs(level[key]) do + table.insert(hooks, hook) + end + end + + return hooks +end + +--[[ + Returns an array of `afterEach` hooks in FILO order +]] +function LifecycleHooks:getAfterEachHooks() + local key = TestEnum.NodeType.AfterEach + local hooks = {} + + for _, level in ipairs(self._stack) do + for _, hook in ipairs(level[key]) do + table.insert(hooks, 1, hook) + end + end + + return hooks +end + +--[[ + Pushes uncalled beforeAll and afterAll hooks back up the stack +]] +function LifecycleHooks:popHooks() + table.remove(self._stack, #self._stack) +end + +function LifecycleHooks:pushHooksFrom(planNode) + assert(planNode ~= nil) + + table.insert(self._stack, { + [TestEnum.NodeType.BeforeAll] = self:_getHooksOfType(planNode.children, TestEnum.NodeType.BeforeAll), + [TestEnum.NodeType.AfterAll] = self:_getHooksOfType(planNode.children, TestEnum.NodeType.AfterAll), + [TestEnum.NodeType.BeforeEach] = self:_getHooksOfType(planNode.children, TestEnum.NodeType.BeforeEach), + [TestEnum.NodeType.AfterEach] = self:_getHooksOfType(planNode.children, TestEnum.NodeType.AfterEach), + }) +end + +--[[ + Get the beforeAll hooks from the current level. +]] +function LifecycleHooks:getBeforeAllHooks() + return self._stack[#self._stack][TestEnum.NodeType.BeforeAll] +end + +--[[ + Get the afterAll hooks from the current level. +]] +function LifecycleHooks:getAfterAllHooks() + return self._stack[#self._stack][TestEnum.NodeType.AfterAll] +end + +function LifecycleHooks:_getHooksOfType(nodes, key) + local hooks = {} + + for _, node in ipairs(nodes) do + if node.type == key then + table.insert(hooks, node.callback) + end + end + + return hooks +end + +return LifecycleHooks diff --git a/TestEZ/Reporters/TeamCityReporter.lua b/TestEZ/Reporters/TeamCityReporter.lua new file mode 100644 index 0000000..bab37e5 --- /dev/null +++ b/TestEZ/Reporters/TeamCityReporter.lua @@ -0,0 +1,102 @@ +local TestService = game:GetService("TestService") + +local TestEnum = require(script.Parent.Parent.TestEnum) + +local TeamCityReporter = {} + +local function teamCityEscape(str) + str = string.gsub(str, "([]|'[])","|%1") + str = string.gsub(str, "\r", "|r") + str = string.gsub(str, "\n", "|n") + return str +end + +local function teamCityEnterSuite(suiteName) + return string.format("##teamcity[testSuiteStarted name='%s']", teamCityEscape(suiteName)) +end + +local function teamCityLeaveSuite(suiteName) + return string.format("##teamcity[testSuiteFinished name='%s']", teamCityEscape(suiteName)) +end + +local function teamCityEnterCase(caseName) + return string.format("##teamcity[testStarted name='%s']", teamCityEscape(caseName)) +end + +local function teamCityLeaveCase(caseName) + return string.format("##teamcity[testFinished name='%s']", teamCityEscape(caseName)) +end + +local function teamCityFailCase(caseName, errorMessage) + return string.format("##teamcity[testFailed name='%s' message='%s']", + teamCityEscape(caseName), teamCityEscape(errorMessage)) +end + +local function reportNode(node, buffer, level) + buffer = buffer or {} + level = level or 0 + if node.status == TestEnum.TestStatus.Skipped then + return buffer + end + if node.planNode.type == TestEnum.NodeType.Describe then + table.insert(buffer, teamCityEnterSuite(node.planNode.phrase)) + for _, child in ipairs(node.children) do + reportNode(child, buffer, level + 1) + end + table.insert(buffer, teamCityLeaveSuite(node.planNode.phrase)) + else + table.insert(buffer, teamCityEnterCase(node.planNode.phrase)) + if node.status == TestEnum.TestStatus.Failure then + table.insert(buffer, teamCityFailCase(node.planNode.phrase, table.concat(node.errors,"\n"))) + end + table.insert(buffer, teamCityLeaveCase(node.planNode.phrase)) + end +end + +local function reportRoot(node) + local buffer = {} + + for _, child in ipairs(node.children) do + reportNode(child, buffer, 0) + end + + return buffer +end + +local function report(root) + local buffer = reportRoot(root) + + return table.concat(buffer, "\n") +end + +function TeamCityReporter.report(results) + local resultBuffer = { + "Test results:", + report(results), + ("%d passed, %d failed, %d skipped"):format( + results.successCount, + results.failureCount, + results.skippedCount + ) + } + + print(table.concat(resultBuffer, "\n")) + + if results.failureCount > 0 then + print(("%d test nodes reported failures."):format(results.failureCount)) + end + + if #results.errors > 0 then + print("Errors reported by tests:") + print("") + + for _, message in ipairs(results.errors) do + TestService:Error(message) + + -- Insert a blank line after each error + print("") + end + end +end + +return TeamCityReporter \ No newline at end of file diff --git a/TestEZ/Reporters/TextReporter.lua b/TestEZ/Reporters/TextReporter.lua new file mode 100644 index 0000000..e40d858 --- /dev/null +++ b/TestEZ/Reporters/TextReporter.lua @@ -0,0 +1,106 @@ +--[[ + The TextReporter uses the results from a completed test to output text to + standard output and TestService. +]] + +local TestService = game:GetService("TestService") + +local TestEnum = require(script.Parent.Parent.TestEnum) + +local INDENT = (" "):rep(3) +local STATUS_SYMBOLS = { + [TestEnum.TestStatus.Success] = "+", + [TestEnum.TestStatus.Failure] = "-", + [TestEnum.TestStatus.Skipped] = "~" +} +local UNKNOWN_STATUS_SYMBOL = "?" + +local TextReporter = {} + +local function compareNodes(a, b) + return a.planNode.phrase:lower() < b.planNode.phrase:lower() +end + +local function reportNode(node, buffer, level) + buffer = buffer or {} + level = level or 0 + + if node.status == TestEnum.TestStatus.Skipped then + return buffer + end + + local line + + if node.status then + local symbol = STATUS_SYMBOLS[node.status] or UNKNOWN_STATUS_SYMBOL + + line = ("%s[%s] %s"):format( + INDENT:rep(level), + symbol, + node.planNode.phrase + ) + else + line = ("%s%s"):format( + INDENT:rep(level), + node.planNode.phrase + ) + end + + table.insert(buffer, line) + table.sort(node.children, compareNodes) + + for _, child in ipairs(node.children) do + reportNode(child, buffer, level + 1) + end + + return buffer +end + +local function reportRoot(node) + local buffer = {} + table.sort(node.children, compareNodes) + + for _, child in ipairs(node.children) do + reportNode(child, buffer, 0) + end + + return buffer +end + +local function report(root) + local buffer = reportRoot(root) + + return table.concat(buffer, "\n") +end + +function TextReporter.report(results) + local resultBuffer = { + "Test results:", + report(results), + ("%d passed, %d failed, %d skipped"):format( + results.successCount, + results.failureCount, + results.skippedCount + ) + } + + print(table.concat(resultBuffer, "\n")) + + if results.failureCount > 0 then + print(("%d test nodes reported failures."):format(results.failureCount)) + end + + if #results.errors > 0 then + print("Errors reported by tests:") + print("") + + for _, message in ipairs(results.errors) do + TestService:Error(message) + + -- Insert a blank line after each error + print("") + end + end +end + +return TextReporter \ No newline at end of file diff --git a/TestEZ/Reporters/TextReporterQuiet.lua b/TestEZ/Reporters/TextReporterQuiet.lua new file mode 100644 index 0000000..cbbb1b4 --- /dev/null +++ b/TestEZ/Reporters/TextReporterQuiet.lua @@ -0,0 +1,97 @@ +--[[ + Copy of TextReporter that doesn't output successful tests. + + This should be temporary, it's just a workaround to make CI environments + happy in the short-term. +]] + +local TestService = game:GetService("TestService") + +local TestEnum = require(script.Parent.Parent.TestEnum) + +local INDENT = (" "):rep(3) +local STATUS_SYMBOLS = { + [TestEnum.TestStatus.Success] = "+", + [TestEnum.TestStatus.Failure] = "-", + [TestEnum.TestStatus.Skipped] = "~" +} +local UNKNOWN_STATUS_SYMBOL = "?" + +local TextReporterQuiet = {} + +local function reportNode(node, buffer, level) + buffer = buffer or {} + level = level or 0 + + if node.status == TestEnum.TestStatus.Skipped then + return buffer + end + + local line + + if node.status ~= TestEnum.TestStatus.Success then + local symbol = STATUS_SYMBOLS[node.status] or UNKNOWN_STATUS_SYMBOL + + line = ("%s[%s] %s"):format( + INDENT:rep(level), + symbol, + node.planNode.phrase + ) + end + + table.insert(buffer, line) + + for _, child in ipairs(node.children) do + reportNode(child, buffer, level + 1) + end + + return buffer +end + +local function reportRoot(node) + local buffer = {} + + for _, child in ipairs(node.children) do + reportNode(child, buffer, 0) + end + + return buffer +end + +local function report(root) + local buffer = reportRoot(root) + + return table.concat(buffer, "\n") +end + +function TextReporterQuiet.report(results) + local resultBuffer = { + "Test results:", + report(results), + ("%d passed, %d failed, %d skipped"):format( + results.successCount, + results.failureCount, + results.skippedCount + ) + } + + print(table.concat(resultBuffer, "\n")) + + if results.failureCount > 0 then + print(("%d test nodes reported failures."):format(results.failureCount)) + end + + if #results.errors > 0 then + print("Errors reported by tests:") + print("") + + for _, message in ipairs(results.errors) do + TestService:Error(message) + + -- Insert a blank line after each error + print("") + end + end +end + +return TextReporterQuiet \ No newline at end of file diff --git a/TestEZ/TestBootstrap.lua b/TestEZ/TestBootstrap.lua new file mode 100644 index 0000000..e3641a5 --- /dev/null +++ b/TestEZ/TestBootstrap.lua @@ -0,0 +1,147 @@ +--[[ + Provides an interface to quickly run and report tests from a given object. +]] + +local TestPlanner = require(script.Parent.TestPlanner) +local TestRunner = require(script.Parent.TestRunner) +local TextReporter = require(script.Parent.Reporters.TextReporter) + +local TestBootstrap = {} + +local function stripSpecSuffix(name) + return (name:gsub("%.spec$", "")) +end +local function isSpecScript(aScript) + return aScript:IsA("ModuleScript") and aScript.Name:match("%.spec$") +end + +local function getPath(module, root) + root = root or game + + local path = {} + local last = module + + if last.Name == "init.spec" then + -- Use the directory's node for init.spec files. + last = last.Parent + end + + while last ~= nil and last ~= root do + table.insert(path, stripSpecSuffix(last.Name)) + last = last.Parent + end + table.insert(path, stripSpecSuffix(root.Name)) + + return path +end + +local function toStringPath(tablePath) + local stringPath = "" + local first = true + for _, element in ipairs(tablePath) do + if first then + stringPath = element + first = false + else + stringPath = element .. " " .. stringPath + end + end + return stringPath +end + +function TestBootstrap:getModulesImpl(root, modules, current) + modules = modules or {} + current = current or root + + if isSpecScript(current) then + local method = require(current) + local path = getPath(current, root) + local pathString = toStringPath(path) + + table.insert(modules, { + method = method, + path = path, + pathStringForSorting = pathString:lower() + }) + end +end + +--[[ + Find all the ModuleScripts in this tree that are tests. +]] +function TestBootstrap:getModules(root) + local modules = {} + + self:getModulesImpl(root, modules) + + for _, child in ipairs(root:GetDescendants()) do + self:getModulesImpl(root, modules, child) + end + + return modules +end + +--[[ + Runs all test and reports the results using the given test reporter. + + If no reporter is specified, a reasonable default is provided. + + This function demonstrates the expected workflow with this testing system: + 1. Locate test modules + 2. Generate test plan + 3. Run test plan + 4. Report test results + + This means we could hypothetically present a GUI to the developer that shows + the test plan before we execute it, allowing them to toggle specific tests + before they're run, but after they've been identified! +]] +function TestBootstrap:run(roots, reporter, otherOptions) + reporter = reporter or TextReporter + + otherOptions = otherOptions or {} + local showTimingInfo = otherOptions["showTimingInfo"] or false + local testNamePattern = otherOptions["testNamePattern"] + local extraEnvironment = otherOptions["extraEnvironment"] or {} + + if type(roots) ~= "table" then + error(("Bad argument #1 to TestBootstrap:run. Expected table, got %s"):format(typeof(roots)), 2) + end + + local startTime = tick() + + local modules = {} + for _, subRoot in ipairs(roots) do + local newModules = self:getModules(subRoot) + + for _, newModule in ipairs(newModules) do + table.insert(modules, newModule) + end + end + + local afterModules = tick() + + local plan = TestPlanner.createPlan(modules, testNamePattern, extraEnvironment) + local afterPlan = tick() + + local results = TestRunner.runPlan(plan) + local afterRun = tick() + + reporter.report(results) + local afterReport = tick() + + if showTimingInfo then + local timing = { + ("Took %f seconds to locate test modules"):format(afterModules - startTime), + ("Took %f seconds to create test plan"):format(afterPlan - afterModules), + ("Took %f seconds to run tests"):format(afterRun - afterPlan), + ("Took %f seconds to report tests"):format(afterReport - afterRun), + } + + print(table.concat(timing, "\n")) + end + + return results +end + +return TestBootstrap \ No newline at end of file diff --git a/TestEZ/TestEnum.lua b/TestEZ/TestEnum.lua new file mode 100644 index 0000000..d8d31b7 --- /dev/null +++ b/TestEZ/TestEnum.lua @@ -0,0 +1,28 @@ +--[[ + Constants used throughout the testing framework. +]] + +local TestEnum = {} + +TestEnum.TestStatus = { + Success = "Success", + Failure = "Failure", + Skipped = "Skipped" +} + +TestEnum.NodeType = { + Describe = "Describe", + It = "It", + BeforeAll = "BeforeAll", + AfterAll = "AfterAll", + BeforeEach = "BeforeEach", + AfterEach = "AfterEach" +} + +TestEnum.NodeModifier = { + None = "None", + Skip = "Skip", + Focus = "Focus" +} + +return TestEnum \ No newline at end of file diff --git a/TestEZ/TestPlan.lua b/TestEZ/TestPlan.lua new file mode 100644 index 0000000..5537f56 --- /dev/null +++ b/TestEZ/TestPlan.lua @@ -0,0 +1,304 @@ +--[[ + Represents a tree of tests that have been loaded but not necessarily + executed yet. + + TestPlan objects are produced by TestPlanner. +]] + +local TestEnum = require(script.Parent.TestEnum) +local Expectation = require(script.Parent.Expectation) + +local function newEnvironment(currentNode, extraEnvironment) + local env = {} + + if extraEnvironment then + if type(extraEnvironment) ~= "table" then + error(("Bad argument #2 to newEnvironment. Expected table, got %s"):format( + typeof(extraEnvironment)), 2) + end + + for key, value in pairs(extraEnvironment) do + env[key] = value + end + end + + local function addChild(phrase, callback, nodeType, nodeModifier) + local node = currentNode:addChild(phrase, nodeType, nodeModifier) + node.callback = callback + if nodeType == TestEnum.NodeType.Describe then + node:expand() + end + return node + end + + function env.describeFOCUS(phrase, callback) + addChild(phrase, callback, TestEnum.NodeType.Describe, TestEnum.NodeModifier.Focus) + end + + function env.describeSKIP(phrase, callback) + addChild(phrase, callback, TestEnum.NodeType.Describe, TestEnum.NodeModifier.Skip) + end + + function env.describe(phrase, callback, nodeModifier) + addChild(phrase, callback, TestEnum.NodeType.Describe, TestEnum.NodeModifier.None) + end + + function env.itFOCUS(phrase, callback) + addChild(phrase, callback, TestEnum.NodeType.It, TestEnum.NodeModifier.Focus) + end + + function env.itSKIP(phrase, callback) + addChild(phrase, callback, TestEnum.NodeType.It, TestEnum.NodeModifier.Skip) + end + + function env.itFIXME(phrase, callback) + local node = addChild(phrase, callback, TestEnum.NodeType.It, TestEnum.NodeModifier.Skip) + warn("FIXME: broken test", node:getFullName()) + end + + function env.it(phrase, callback, nodeModifier) + addChild(phrase, callback, TestEnum.NodeType.It, TestEnum.NodeModifier.None) + end + + -- Incrementing counter used to ensure that beforeAll, afterAll, beforeEach, afterEach have unique phrases + local lifecyclePhaseId = 0 + + local lifecycleHooks = { + [TestEnum.NodeType.BeforeAll] = "beforeAll", + [TestEnum.NodeType.AfterAll] = "afterAll", + [TestEnum.NodeType.BeforeEach] = "beforeEach", + [TestEnum.NodeType.AfterEach] = "afterEach" + } + + for nodeType, name in pairs(lifecycleHooks) do + env[name] = function(callback) + addChild(name .. "_" .. tostring(lifecyclePhaseId), callback, nodeType, TestEnum.NodeModifier.None) + lifecyclePhaseId = lifecyclePhaseId + 1 + end + end + + function env.FIXME(optionalMessage) + warn("FIXME: broken test", currentNode:getFullName(), optionalMessage or "") + + currentNode.modifier = TestEnum.NodeModifier.Skip + end + + function env.FOCUS() + currentNode.modifier = TestEnum.NodeModifier.Focus + end + + function env.SKIP() + currentNode.modifier = TestEnum.NodeModifier.Skip + end + + --[[ + This function is deprecated. Calling it is a no-op beyond generating a + warning. + ]] + function env.HACK_NO_XPCALL() + warn("HACK_NO_XPCALL is deprecated. It is now safe to yield in an " .. + "xpcall, so this is no longer necessary. It can be safely deleted.") + end + + env.fit = env.itFOCUS + env.xit = env.itSKIP + env.fdescribe = env.describeFOCUS + env.xdescribe = env.describeSKIP + + env.expect = setmetatable({ + extend = function(...) + error("Cannot call \"expect.extend\" from within a \"describe\" node.") + end, + }, { + __call = function(_self, ...) + return Expectation.new(...) + end, + }) + + return env +end + +local TestNode = {} +TestNode.__index = TestNode + +--[[ + Create a new test node. A pointer to the test plan, a phrase to describe it + and the type of node it is are required. The modifier is optional and will + be None if left blank. +]] +function TestNode.new(plan, phrase, nodeType, nodeModifier) + nodeModifier = nodeModifier or TestEnum.NodeModifier.None + + local node = { + plan = plan, + phrase = phrase, + type = nodeType, + modifier = nodeModifier, + children = {}, + callback = nil, + parent = nil, + } + + node.environment = newEnvironment(node, plan.extraEnvironment) + return setmetatable(node, TestNode) +end + +local function getModifier(name, pattern, modifier) + if pattern and (modifier == nil or modifier == TestEnum.NodeModifier.None) then + if name:match(pattern) then + return TestEnum.NodeModifier.Focus + else + return TestEnum.NodeModifier.Skip + end + end + return modifier +end + +function TestNode:addChild(phrase, nodeType, nodeModifier) + if nodeType == TestEnum.NodeType.It then + for _, child in pairs(self.children) do + if child.phrase == phrase then + error("Duplicate it block found: " .. child:getFullName()) + end + end + end + + local childName = self:getFullName() .. " " .. phrase + nodeModifier = getModifier(childName, self.plan.testNamePattern, nodeModifier) + local child = TestNode.new(self.plan, phrase, nodeType, nodeModifier) + child.parent = self + table.insert(self.children, child) + return child +end + +--[[ + Join the names of all the nodes back to the parent. +]] +function TestNode:getFullName() + if self.parent then + local parentPhrase = self.parent:getFullName() + if parentPhrase then + return parentPhrase .. " " .. self.phrase + end + end + return self.phrase +end + +--[[ + Expand a node by setting its callback environment and then calling it. Any + further it and describe calls within the callback will be added to the tree. +]] +function TestNode:expand() + local originalEnv = getfenv(self.callback) + local callbackEnv = setmetatable({}, { __index = originalEnv }) + for key, value in pairs(self.environment) do + callbackEnv[key] = value + end + -- Copy 'script' directly to new env to make Studio debugger happy. + -- Studio debugger does not look into __index, because of security reasons + callbackEnv.script = originalEnv.script + setfenv(self.callback, callbackEnv) + + local success, result = xpcall(self.callback, function(message) + return debug.traceback(tostring(message), 2) + end) + + if not success then + self.loadError = result + end +end + +local TestPlan = {} +TestPlan.__index = TestPlan + +--[[ + Create a new, empty TestPlan. +]] +function TestPlan.new(testNamePattern, extraEnvironment) + local plan = { + children = {}, + testNamePattern = testNamePattern, + extraEnvironment = extraEnvironment, + } + + return setmetatable(plan, TestPlan) +end + +--[[ + Add a new child under the test plan's root node. +]] +function TestPlan:addChild(phrase, nodeType, nodeModifier) + nodeModifier = getModifier(phrase, self.testNamePattern, nodeModifier) + local child = TestNode.new(self, phrase, nodeType, nodeModifier) + table.insert(self.children, child) + return child +end + +--[[ + Add a new describe node with the given method as a callback. Generates or + reuses all the describe nodes along the path. +]] +function TestPlan:addRoot(path, method) + local curNode = self + for i = #path, 1, -1 do + local nextNode = nil + + for _, child in ipairs(curNode.children) do + if child.phrase == path[i] then + nextNode = child + break + end + end + + if nextNode == nil then + nextNode = curNode:addChild(path[i], TestEnum.NodeType.Describe) + end + + curNode = nextNode + end + + curNode.callback = method + curNode:expand() +end + +--[[ + Calls the given callback on all nodes in the tree, traversed depth-first. +]] +function TestPlan:visitAllNodes(callback, root, level) + root = root or self + level = level or 0 + + for _, child in ipairs(root.children) do + callback(child, level) + + self:visitAllNodes(callback, child, level + 1) + end +end + +--[[ + Visualizes the test plan in a simple format, suitable for debugging the test + plan's structure. +]] +function TestPlan:visualize() + local buffer = {} + self:visitAllNodes(function(node, level) + table.insert(buffer, (" "):rep(3 * level) .. node.phrase) + end) + return table.concat(buffer, "\n") +end + +--[[ + Gets a list of all nodes in the tree for which the given callback returns + true. +]] +function TestPlan:findNodes(callback) + local results = {} + self:visitAllNodes(function(node) + if callback(node) then + table.insert(results, node) + end + end) + return results +end + +return TestPlan diff --git a/TestEZ/TestPlanner.lua b/TestEZ/TestPlanner.lua new file mode 100644 index 0000000..6612ff5 --- /dev/null +++ b/TestEZ/TestPlanner.lua @@ -0,0 +1,40 @@ +--[[ + Turns a series of specification functions into a test plan. + + Uses a TestPlanBuilder to keep track of the state of the tree being built. +]] +local TestPlan = require(script.Parent.TestPlan) + +local TestPlanner = {} + +--[[ + Create a new TestPlan from a list of specification functions. + + These functions should call a combination of `describe` and `it` (and their + variants), which will be turned into a test plan to be executed. + + Parameters: + - modulesList - list of tables describing test modules { + method, -- specification function described above + path, -- array of parent entires, first element is the leaf that owns `method` + pathStringForSorting -- a string representation of `path`, used for sorting of the test plan + } + - testNamePattern - Only tests matching this Lua pattern string will run. Pass empty or nil to run all tests + - extraEnvironment - Lua table holding additional functions and variables to be injected into the specification + function during execution +]] +function TestPlanner.createPlan(modulesList, testNamePattern, extraEnvironment) + local plan = TestPlan.new(testNamePattern, extraEnvironment) + + table.sort(modulesList, function(a, b) + return a.pathStringForSorting < b.pathStringForSorting + end) + + for _, module in ipairs(modulesList) do + plan:addRoot(module.path, module.method) + end + + return plan +end + +return TestPlanner \ No newline at end of file diff --git a/TestEZ/TestResults.lua b/TestEZ/TestResults.lua new file mode 100644 index 0000000..c39c829 --- /dev/null +++ b/TestEZ/TestResults.lua @@ -0,0 +1,112 @@ +--[[ + Represents a tree of test results. + + Each node in the tree corresponds directly to a node in a corresponding + TestPlan, accessible via the 'planNode' field. + + TestResults objects are produced by TestRunner using TestSession as state. +]] + +local TestEnum = require(script.Parent.TestEnum) + +local STATUS_SYMBOLS = { + [TestEnum.TestStatus.Success] = "+", + [TestEnum.TestStatus.Failure] = "-", + [TestEnum.TestStatus.Skipped] = "~" +} + +local TestResults = {} + +TestResults.__index = TestResults + +--[[ + Create a new TestResults tree that's linked to the given TestPlan. +]] +function TestResults.new(plan) + local self = { + successCount = 0, + failureCount = 0, + skippedCount = 0, + planNode = plan, + children = {}, + errors = {} + } + + setmetatable(self, TestResults) + + return self +end + +--[[ + Create a new result node that can be inserted into a TestResult tree. +]] +function TestResults.createNode(planNode) + local node = { + planNode = planNode, + children = {}, + errors = {}, + status = nil + } + + return node +end + +--[[ + Visit all test result nodes, depth-first. +]] +function TestResults:visitAllNodes(callback, root) + root = root or self + + for _, child in ipairs(root.children) do + callback(child) + + self:visitAllNodes(callback, child) + end +end + +--[[ + Creates a debug visualization of the test results. +]] +function TestResults:visualize(root, level) + root = root or self + level = level or 0 + + local buffer = {} + + for _, child in ipairs(root.children) do + if child.planNode.type == TestEnum.NodeType.It then + local symbol = STATUS_SYMBOLS[child.status] or "?" + local str = ("%s[%s] %s"):format( + (" "):rep(3 * level), + symbol, + child.planNode.phrase + ) + + if child.messages and #child.messages > 0 then + str = str .. "\n " .. (" "):rep(3 * level) .. table.concat(child.messages, "\n " .. (" "):rep(3 * level)) + end + + table.insert(buffer, str) + else + local str = ("%s%s"):format( + (" "):rep(3 * level), + child.planNode.phrase or "" + ) + + if child.status then + str = str .. (" (%s)"):format(child.status) + end + + table.insert(buffer, str) + + if #child.children > 0 then + local text = self:visualize(child, level + 1) + table.insert(buffer, text) + end + end + end + + return table.concat(buffer, "\n") +end + +return TestResults \ No newline at end of file diff --git a/TestEZ/TestRunner.lua b/TestEZ/TestRunner.lua new file mode 100644 index 0000000..2ccff81 --- /dev/null +++ b/TestEZ/TestRunner.lua @@ -0,0 +1,188 @@ +--[[ + Contains the logic to run a test plan and gather test results from it. + + TestRunner accepts a TestPlan object, executes the planned tests, and + produces a TestResults object. While the tests are running, the system's + state is contained inside a TestSession object. +]] + +local TestEnum = require(script.Parent.TestEnum) +local TestSession = require(script.Parent.TestSession) +local LifecycleHooks = require(script.Parent.LifecycleHooks) + +local RUNNING_GLOBAL = "__TESTEZ_RUNNING_TEST__" + +local TestRunner = { + environment = {} +} + +local function wrapExpectContextWithPublicApi(expectationContext) + return setmetatable({ + extend = function(...) + expectationContext:extend(...) + end, + }, { + __call = function(_self, ...) + return expectationContext:startExpectationChain(...) + end, + }) +end + +--[[ + Runs the given TestPlan and returns a TestResults object representing the + results of the run. +]] +function TestRunner.runPlan(plan) + local session = TestSession.new(plan) + local lifecycleHooks = LifecycleHooks.new() + + local exclusiveNodes = plan:findNodes(function(node) + return node.modifier == TestEnum.NodeModifier.Focus + end) + + session.hasFocusNodes = #exclusiveNodes > 0 + + TestRunner.runPlanNode(session, plan, lifecycleHooks) + + return session:finalize() +end + +--[[ + Run the given test plan node and its descendants, using the given test + session to store all of the results. +]] +function TestRunner.runPlanNode(session, planNode, lifecycleHooks) + local function runCallback(callback, messagePrefix) + local success = true + local errorMessage + -- Any code can check RUNNING_GLOBAL to fork behavior based on + -- whether a test is running. We use this to avoid accessing + -- protected APIs; it's a workaround that will go away someday. + _G[RUNNING_GLOBAL] = true + + messagePrefix = messagePrefix or "" + + local testEnvironment = getfenv(callback) + + for key, value in pairs(TestRunner.environment) do + testEnvironment[key] = value + end + + testEnvironment.fail = function(message) + if message == nil then + message = "fail() was called." + end + + success = false + errorMessage = messagePrefix .. debug.traceback(tostring(message), 2) + end + + testEnvironment.expect = wrapExpectContextWithPublicApi(session:getExpectationContext()) + + local context = session:getContext() + + local nodeSuccess, nodeResult = xpcall( + function() + callback(context) + end, + function(message) + return messagePrefix .. debug.traceback(tostring(message), 2) + end + ) + + -- If a node threw an error, we prefer to use that message over + -- one created by fail() if it was set. + if not nodeSuccess then + success = false + errorMessage = nodeResult + end + + _G[RUNNING_GLOBAL] = nil + + return success, errorMessage + end + + local function runNode(childPlanNode) + -- Errors can be set either via `error` propagating upwards or + -- by a test calling fail([message]). + + for _, hook in ipairs(lifecycleHooks:getBeforeEachHooks()) do + local success, errorMessage = runCallback(hook, "beforeEach hook: ") + if not success then + return false, errorMessage + end + end + + local testSuccess, testErrorMessage = runCallback(childPlanNode.callback) + + for _, hook in ipairs(lifecycleHooks:getAfterEachHooks()) do + local success, errorMessage = runCallback(hook, "afterEach hook: ") + if not success then + if not testSuccess then + return false, testErrorMessage .. "\nWhile cleaning up the failed test another error was found:\n" .. errorMessage + end + return false, errorMessage + end + end + + if not testSuccess then + return false, testErrorMessage + end + + return true, nil + end + + lifecycleHooks:pushHooksFrom(planNode) + + local halt = false + for _, hook in ipairs(lifecycleHooks:getBeforeAllHooks()) do + local success, errorMessage = runCallback(hook, "beforeAll hook: ") + if not success then + session:addDummyError("beforeAll", errorMessage) + halt = true + end + end + + if not halt then + for _, childPlanNode in ipairs(planNode.children) do + if childPlanNode.type == TestEnum.NodeType.It then + session:pushNode(childPlanNode) + if session:shouldSkip() then + session:setSkipped() + else + local success, errorMessage = runNode(childPlanNode) + + if success then + session:setSuccess() + else + session:setError(errorMessage) + end + end + session:popNode() + elseif childPlanNode.type == TestEnum.NodeType.Describe then + session:pushNode(childPlanNode) + TestRunner.runPlanNode(session, childPlanNode, lifecycleHooks) + + -- Did we have an error trying build a test plan? + if childPlanNode.loadError then + local message = "Error during planning: " .. childPlanNode.loadError + session:setError(message) + else + session:setStatusFromChildren() + end + session:popNode() + end + end + end + + for _, hook in ipairs(lifecycleHooks:getAfterAllHooks()) do + local success, errorMessage = runCallback(hook, "afterAll hook: ") + if not success then + session:addDummyError("afterAll", errorMessage) + end + end + + lifecycleHooks:popHooks() +end + +return TestRunner diff --git a/TestEZ/TestSession.lua b/TestEZ/TestSession.lua new file mode 100644 index 0000000..285e11c --- /dev/null +++ b/TestEZ/TestSession.lua @@ -0,0 +1,243 @@ +--[[ + Represents the state relevant while executing a test plan. + + Used by TestRunner to produce a TestResults object. + + Uses the same tree building structure as TestPlanBuilder; TestSession keeps + track of a stack of nodes that represent the current path through the tree. +]] + +local TestEnum = require(script.Parent.TestEnum) +local TestResults = require(script.Parent.TestResults) +local Context = require(script.Parent.Context) +local ExpectationContext = require(script.Parent.ExpectationContext) + +local TestSession = {} + +TestSession.__index = TestSession + +--[[ + Create a TestSession related to the given TestPlan. + + The resulting TestResults object will be linked to this TestPlan. +]] +function TestSession.new(plan) + local self = { + results = TestResults.new(plan), + nodeStack = {}, + contextStack = {}, + expectationContextStack = {}, + hasFocusNodes = false + } + + setmetatable(self, TestSession) + + return self +end + +--[[ + Calculate success, failure, and skipped test counts in the tree at the + current point in the execution. +]] +function TestSession:calculateTotals() + local results = self.results + + results.successCount = 0 + results.failureCount = 0 + results.skippedCount = 0 + + results:visitAllNodes(function(node) + local status = node.status + local nodeType = node.planNode.type + + if nodeType == TestEnum.NodeType.It then + if status == TestEnum.TestStatus.Success then + results.successCount = results.successCount + 1 + elseif status == TestEnum.TestStatus.Failure then + results.failureCount = results.failureCount + 1 + elseif status == TestEnum.TestStatus.Skipped then + results.skippedCount = results.skippedCount + 1 + end + end + end) +end + +--[[ + Gathers all of the errors reported by tests and puts them at the top level + of the TestResults object. +]] +function TestSession:gatherErrors() + local results = self.results + + results.errors = {} + + results:visitAllNodes(function(node) + if #node.errors > 0 then + for _, message in ipairs(node.errors) do + table.insert(results.errors, message) + end + end + end) +end + +--[[ + Calculates test totals, verifies the tree is valid, and returns results. +]] +function TestSession:finalize() + if #self.nodeStack ~= 0 then + error("Cannot finalize TestResults with nodes still on the stack!", 2) + end + + self:calculateTotals() + self:gatherErrors() + + return self.results +end + +--[[ + Create a new test result node and push it onto the navigation stack. +]] +function TestSession:pushNode(planNode) + local node = TestResults.createNode(planNode) + local lastNode = self.nodeStack[#self.nodeStack] or self.results + table.insert(lastNode.children, node) + table.insert(self.nodeStack, node) + + local lastContext = self.contextStack[#self.contextStack] + local context = Context.new(lastContext) + table.insert(self.contextStack, context) + + local lastExpectationContext = self.expectationContextStack[#self.expectationContextStack] + local expectationContext = ExpectationContext.new(lastExpectationContext) + table.insert(self.expectationContextStack, expectationContext) +end + +--[[ + Pops a node off of the navigation stack. +]] +function TestSession:popNode() + assert(#self.nodeStack > 0, "Tried to pop from an empty node stack!") + table.remove(self.nodeStack, #self.nodeStack) + table.remove(self.contextStack, #self.contextStack) + table.remove(self.expectationContextStack, #self.expectationContextStack) +end + +--[[ + Gets the Context object for the current node. +]] +function TestSession:getContext() + assert(#self.contextStack > 0, "Tried to get context from an empty stack!") + return self.contextStack[#self.contextStack] +end + + +function TestSession:getExpectationContext() + assert(#self.expectationContextStack > 0, "Tried to get expectationContext from an empty stack!") + return self.expectationContextStack[#self.expectationContextStack] +end + +--[[ + Tells whether the current test we're in should be skipped. +]] +function TestSession:shouldSkip() + -- If our test tree had any exclusive tests, then normal tests are skipped! + if self.hasFocusNodes then + for i = #self.nodeStack, 1, -1 do + local node = self.nodeStack[i] + + -- Skipped tests are still skipped + if node.planNode.modifier == TestEnum.NodeModifier.Skip then + return true + end + + -- Focused tests are the only ones that aren't skipped + if node.planNode.modifier == TestEnum.NodeModifier.Focus then + return false + end + end + + return true + else + for i = #self.nodeStack, 1, -1 do + local node = self.nodeStack[i] + + if node.planNode.modifier == TestEnum.NodeModifier.Skip then + return true + end + end + end + + return false +end + +--[[ + Set the current node's status to Success. +]] +function TestSession:setSuccess() + assert(#self.nodeStack > 0, "Attempting to set success status on empty stack") + self.nodeStack[#self.nodeStack].status = TestEnum.TestStatus.Success +end + +--[[ + Set the current node's status to Skipped. +]] +function TestSession:setSkipped() + assert(#self.nodeStack > 0, "Attempting to set skipped status on empty stack") + self.nodeStack[#self.nodeStack].status = TestEnum.TestStatus.Skipped +end + +--[[ + Set the current node's status to Failure and adds a message to its list of + errors. +]] +function TestSession:setError(message) + assert(#self.nodeStack > 0, "Attempting to set error status on empty stack") + local last = self.nodeStack[#self.nodeStack] + last.status = TestEnum.TestStatus.Failure + table.insert(last.errors, message) +end + +--[[ + Add a dummy child node to the current node to hold the given error. This + allows an otherwise empty describe node to report an error in a more natural + way. +]] +function TestSession:addDummyError(phrase, message) + self:pushNode({type = TestEnum.NodeType.It, phrase = phrase}) + self:setError(message) + self:popNode() + self.nodeStack[#self.nodeStack].status = TestEnum.TestStatus.Failure +end + +--[[ + Set the current node's status based on that of its children. If all children + are skipped, mark it as skipped. If any are fails, mark it as failed. + Otherwise, mark it as success. +]] +function TestSession:setStatusFromChildren() + assert(#self.nodeStack > 0, "Attempting to set status from children on empty stack") + + local last = self.nodeStack[#self.nodeStack] + local status = TestEnum.TestStatus.Success + local skipped = true + + -- If all children were skipped, then we were skipped + -- If any child failed, then we failed! + for _, child in ipairs(last.children) do + if child.status ~= TestEnum.TestStatus.Skipped then + skipped = false + + if child.status == TestEnum.TestStatus.Failure then + status = TestEnum.TestStatus.Failure + end + end + end + + if skipped then + status = TestEnum.TestStatus.Skipped + end + + last.status = status +end + +return TestSession diff --git a/TestEZ/init.lua b/TestEZ/init.lua new file mode 100644 index 0000000..c83ec4c --- /dev/null +++ b/TestEZ/init.lua @@ -0,0 +1,42 @@ +--!native +--!optimize 2 +local Expectation = require(script.Expectation) +local TestBootstrap = require(script.TestBootstrap) +local TestEnum = require(script.TestEnum) +local TestPlan = require(script.TestPlan) +local TestPlanner = require(script.TestPlanner) +local TestResults = require(script.TestResults) +local TestRunner = require(script.TestRunner) +local TestSession = require(script.TestSession) +local TextReporter = require(script.Reporters.TextReporter) +local TextReporterQuiet = require(script.Reporters.TextReporterQuiet) +local TeamCityReporter = require(script.Reporters.TeamCityReporter) + +local function run(testRoot, callback) + local modules = TestBootstrap:getModules(testRoot) + local plan = TestPlanner.createPlan(modules) + local results = TestRunner.runPlan(plan) + + callback(results) +end + +local TestEZ = { + run = run, + + Expectation = Expectation, + TestBootstrap = TestBootstrap, + TestEnum = TestEnum, + TestPlan = TestPlan, + TestPlanner = TestPlanner, + TestResults = TestResults, + TestRunner = TestRunner, + TestSession = TestSession, + + Reporters = { + TextReporter = TextReporter, + TextReporterQuiet = TextReporterQuiet, + TeamCityReporter = TeamCityReporter, + }, +} + +return TestEZ \ No newline at end of file diff --git a/Warp.rbxm b/Warp.rbxm index 9e7c5aa51e6027f9ee070146819eb4bb4a6f4a8c..d9a10c227d531f82e7e549bbfe75ecf0d34d4f80 100644 GIT binary patch literal 12089 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 bN|t(65%sU^JJP>Y{aZQ~%=@fhe$oE{$#+$P literal 11933 zcmZ{K33yx8weDJb?=wpBmSlNO;$u5;q*#(AS@J}jMDjpFCdWybl8`92oQOD*k>reo zAPOtGN?91I2z zJ!7|MptJv;mD|2;zp5`Yn$2a3|FfYjM!Ufb`nAVrANzIt+ZNKI^u`R@FQlPu$`nj^ zvv(|;$(8K?mY1?)cFQI@57Wm!_G|XHJ+-CziNbIuSuAEs^G50_zaJeU+D&Tc0sCVg zd+jf~zh&ulMRfi9#O)ESp^ts+ciO`K|46J3B!j@e&;J*Pn$aq0KdjRKoOlB*QvUxm zm`)G>AmV?N{qUy$r@-)k$k|?*W4?dSe&vVvEXd|IWJbOdf$#qs*^Tt{!{4{qYd83B zIUBI|sB~dmp?mXkBbhA*4EAp#Vb{`{FWWkvIV9FY>o8cz4`+(SMo?M+4S*&X{%&2G zU_Y~$%a(hg#OZ8)W@{Vo}4rQWF?Tmof{o8?&V|}@VozfpZksZNiy4;>j#5()> z+Y_PQu7UPYyuCdVqFb?0B0)dxiOzU`Z)C>|I8f2q))$HNMiZe_XCfAgcf=y0u8w#z zG|-uh_9ar$j?Q@R4ERGV-qjcBOhiM;_WosXK-j?X>x4UcW zNW{B31_nX{{i#0ct}hnqj3wGb1APOjSiG~Zvn!FD0iXGz(e}R7KwCVNjKw=b@x(x1 zsIz|{8fuFs+M?~TR7W&!cNg!BM7ugVlA%OqsQqLtVXXkx)E7K(iQVkB1^%k$5T|i>2B-zNYT_9LN^SX)~lO&QvUstFr8XXA52722Qv1n%-Er30?2&quCFV+|8igi+V z9lgm=vOf_C(FzX?bkIany$jg>%Curli z+jiJ${3EjUsuM|7>DWKpR^vk+?vTU6`E(r$S+*fs3$kkq>B3gaKc~NxDde)Ht*iSo zn~DLG9w2X6%;s}eG#m+c(2a&7dGX%I|iWlK3IG_ehvydrG6mr&f)A-KYT0JrFh9&$oXd2&{cQr5x1-cw9 zwvNupjE@2^Z@m7$b1*tEqEU=7nJqce+~xC9VZ!!QA|ct z!M5!?%qolwP2_$LZ)b-yG`4l*^1Bbul)TXFZ=3>6je#gw^NLdd8&I7BzV1qx>JJ

>vH;82rA`xe?PGoyZNBo?>_=<43_>|GW`$pV`rDrqFFYfcqi0E@%~g@O$0XCr}Y~!_s77 z-RwXGuq&X^G(G{J|52C|wAQRx&hJ>&ZS|(d#V)?sA-&$&GA8b}4FeKx{;@ z2w4LtA3-9QFI6(_6;OtdwL;ktYBN!z@AW8$Fi!%aTk7G+8UYVS=UjBWCoF$jQLZU= zTidO4ZmYFp5jv)WaumvInc;{-qksEKkTBU>b9j$Z&rAEoDEDDjB2sf;FBlq&G&p_klO)0#D2ZDJz?^#^6+IBySogL*TvPodIZ2las)F~*)z<(mQ( zg}ItQet{aJC>%rA7pS~}d^Y0|M5l_&Pr z(IUV+5I&OcG}O2u#|c<`9%<4EDodAWX3CM}|5J z_?=9Sfn)aaTpa~?qSi#ND-c3xx24GeZ(QW&`yr)I8f_B^?^G$3ovVY9PR#KR~_A%WcYU=!Oz`^h|!D zbRrm?@EScqfA!{bIdTCd_#OH9v9;;p4b<@%U=i3hN0J^`{1G^A0qroDGd`BydcgDy z>R_A<&iSyoFIybm1tPH_vo+yR4uBeg!;)iTrvqLKSdAz5(FzpuTP;v$9+l314M|aiKBLb@d`e9O_=?#8{6~$kIoCmsbHkN&kq8nVFqdXOKDHs<=fP4a*3?Gy8 zp+$WMT^33ot7i1Fmk>MA>0?d{=znc8&>xCOdA!@7M)gL(8YsI5;T;}YmM3%geVO=( zl^q6-_c%LFH>U$@W|gxmgxmm@)(1{Yh$3i{7?~_zgQWJ-5?mezb@FROz69x~2HOCC z3ivgeuiC*vyozO$nMzS136A>|oTk#8#L4RPZfl8d>{6S%t=EaU1mz=%vF0NFb#{jeXdj$q$kK>~PCUy{p?-PN#1Q}3*R znaG}~a6MSTZUBCGIs6yMhkeu87ixX+6bDjNR6g>-ET4Lw&LWUvPjr-IdOndk{S!9fn_ zNDx(=h9o3N3ak8A92WwQ0JT&?z|RHu3D;)0PPgI)0+ifcG*V(hm(MIzjBJ!fkf z`&i;kXq=Zj3{;|0Z*X-U(5jpK*T7Q1UJzwF0G{z^mjf(;BsupTox=5Fn7w$Sv>f96 zb>NFA4xH#Ms{&l*6;+__0JGHwEg}N^WE5v8_yUU(M$y4X$bnA-^-KlpK_Q(a2t@L{ z3Cw2TB&!R#vZCs*S;B1JG3x&G@cP@Z*1z4T2;5ypPy=;YZe1tfb00iZ4k?L2OwmAA z_`$O(06jE1dM1eMgmUov%6fH};$gpPQD)4d{n~}`&I(yVG74&Z-u|b@ePR+Eo;!{Y)+< z)7yupj6oGY5@8BP45Q>O=dVD$zYi)SL2Em>(Ocm)W(ihAbA*$e7@l9&KJn(Qtl#Ln8@V_j*(Bc^2P8l;pz?k7i*x_3pY-|`Q^hS z8FI1yx1l0WPAbAkW{z~8%z0P^v!;v-Q-YO%cn zYwKLg5g8^(`Cc+l4-be3~TRV)m(|quTMRd6_DkL6Hl9H<(8E zTg75EOtGLRn&B~H^k z8|4*@X)n0dm7WsexA8(YS5QO}^cHVZz-%(aWYGVHb5>Bi`;!nid1NOyM+%vZ`AwO_ zL0$_kLLlDRv`j@XF{!`-p)Rle0>Xp&Vrgl1`^kUkMF$W3Hoj%LG{>BO*e zh~QKq^9MHVM;$2naD@3NpCaH#DHTnx$#bdf)v#bq0M#l0216;<%AXjy0%8nicAr#{ZR67w) zK)jDUnI>)p$0#BR>oz_}I4CnRinT?W`Tz(ejkthovp}9H3^flGn=+tNq*xB(5|}oc z3xGTbI^lGW;q=A*qq!8Ph1i6IB9DhQ|E9od)UGCa0_;!19$F``4~1NUIv)h36SPxM zb~A@O7cr6@+4B0-#q|2pF7h@*glr9jGeMgR@?tDkqh!qj{u9|*AU3Gf7r&It%dwe% z7=WWSSWKZG*d8Yr+&gqyFw;XUX*74G%Fai=2i5Cg&5GEnK)~8;5_@pU&ohLN z!=qeV3fd!_?*ipv&RQ7%42gM*KZ1fx`AUdgVDvKiFgp0Lpez9RBk~iO=mPl&aJi77 zc!{$|QJKf(Ae5`~8UGa329%STPI+i80E>xCMlaND0l=|<2guiO{#St2V0b{rR81w= zxE+jkz>6xXdiF5NMXS^4<-kJ1?10wTs*TJc_M_6O^1X^N0Iu5s>ZX@(d7V5D;PXCp z0VoI1n8URBOl<)67L&^vlamCsGKBQt2 zU<>AP z2f;{z{08REi;x100e=IGZ$T2}6o6c(>;jPAX3q+Csv`C{8?3x5`Wa>li!!Cn`ND>w z%y5SCFS`^q!Ss7f{JKTi;ebnlKkHDZ5xjK6c?L8SKoUIV!E>O>2!OBWeCJH^2|so+ zsu;`#R^YA;99NK#xwJrGv&;EPPB@03kr0h4#cP*BG-)Lb=mbT-)lecJb_+X{8_R%w z!}T{*c`R|(^N>B}g@^0eYkr*IP2t6FP+S1z(K4cqygG18Ge$wqhROPVE@x8LTbW$T zq$eGS$xmD$3v7u08u{}ey-uM786ksm{yMV9tJRfY=7kbr1Pxe>%lp{^Hiq!5fGvvh z63Cqlr57E_E~eblD*Fgx{EL8PAHZ`A?-rn<+k8faeL6leUHn`?pDqtCFVhZI$o_H% zt|uTRjOOZ^Kd)>z9&M2KHVD@2Bi7U#UcEYdeh0Btapz?INx8hdAm)2y31$CLhmVBR zw5rY#%JM3ise+2xlWJf;l_i?enbO8gDP2`Jni0yRkX*7?W@MQw*Gm$&#izzxHx5arVziXV7Sib^Y59Y z)d!s?nc8bLY$=MzfW5Ec+Zyg^!9Rgm0%9)U9N@nu=B4tVA?}3w;%R_d5oAhC=hF_8 zzkvq)qrhW?yd5y#zhyWxUb+X+lc|L^O3pNkR%^%8kRWf{;u)gS%?N=3F^rzTH;VHa zhr9`=lbvX+cZS$DNv#dbHm2fslNMe6gf#OPs+wecb66|~Oro5_gebGbHPx61ySJtU zI~fTPr3>p4PQEAL3#=02NQY;`?qL;dqw@i_2%5f(9NXJ}Tnz}$=3`ATTsVdGnf-J`X(>@nlYE9fuE^r<%W5)VcW#v2D`+tZDND*olU@9 z=-6dwLoMtH-TB|v;5M9;46OwHW`kV~umiOkLrXJuFNb?ko^K$VW%tEz(bWY8?CBu2 zr@Qmm`M`f_|F1YNAg0Du3XmUy<2i*L1NawRHt3+etW%^CI|&yUPCU?v1{Z%3UTY4> z3pG_a9?YE@YX@@^c@9mJn?aM?h_-LglIAV;PYYWB%u>bGDm)sIjrtt60mLZEyFn9w zLb*~KVCRrK)Gpz-g1TDglT^C!Qr<{xLUuE!i#S^W+8y@o!w6N7oV`+^q{Nmu-D$A} z`i9baX3b}@DjEjA57@BgH4Rn0v`Myj>@1LcJoc@do!t*1KMp0h%Olq(K)7OzqWNW# zhYk_d@fiChs_#u@kJCScEbvF#|TX%PV6sN~S18BZu=FiFB+UCKj354eCQsZgzpPhko_}uR-`?62>bX zRUj^lk&Ds(R%`q-_C0T1jK*NKd?L0$JfiXobZ!WKNf`eNWyl1z*{f~#D!NEx^(8)0Y%!rB8&-%|sfmfRQS21TVC7!fN;Iv|S*n(tFcL%EN*`m=5|=+WK> zb~eX#Wzq_t#Tt;mO^G&Sj#(b_WgSm3;Vdbu!#-IPo?u5ZB3{twl)tJ(XH8hGkFgGP zMNmA=7}4n$6@5F45bYwnSQMrQU>>r=po|d55kmiKh&=)_04mW9`Dawv)0#~*-cuac z0gN1>{53>rRkXX7l(QbUq~LWGv+DBUo}wa)|@&m zM!yP=Uh35;`i$Ols< z6Rd!9o$>-i>JL8<@L4&f&v7f4e}iu0&l--nlIXoR_w#4lUqw=EjG9Qi>F|H9=s5ruzg`f||P{4g+8|FVTNlZ|uvMT?71Wt8)2 zAxLfLzhvTXiqe2=^K_VGXPk6LHFXP)2L)W%==^()vUaLYrJGL58BS;QVM;H+d(}44 zp;Yo`O#T8ivTcH{LIdSmE8{pf6KH4z>Agg^=6IV z!sJ^)7bgP$W{^G4B&n0Q3yn8|Y&mjIi}q5G_b{~&wQ^u_VCUFbJTP`r_MFqI@*BA0 z(?)Yp!PSUYR8vNPfH|wFgNqoMkVAP3;0e@blP^Ipn%$sSg7{D)^)WuV7z5DeK6tI!JP+_mVCzu}D#qoM&}Ed|AmeYTkL`oykPPZaAlwH=PY^HU z+CC_I8qjt1spPW7S)85DI3Zy2=4_G^pGLN4in73IBq;|#@Sq#; z6EF@s2A^xtv0ouF=(84-y?jNLFp1g5~-3|dy28Jr94wq=-#PPU<0RKpK-%S3ag)dr}LLpyR zNP8;M>oO-GY+DVrwR}CQ?ZDi|d?%Q`z)QZ0*>5!wi`iY} z_TLE;xpm><#9DuYZ_NT)>v<0p9KwxowLj(H~WO1RF zJ>Mh(Q_E-XbJv*%1=Y@qY24ZgFHT_|WK&_zdfJCH`!Kw;mr9d^lwF1fWAv32#Cl2B z8>}A6Y+M5(+9-%;5u$9J9#Qw3jAv1rvv-F;4aW- z^zl#%3jhZ_YHuHHCYjin@wY*F_bHqT#&|4o&?a#=SpLx-rk}Bd&`rbazzihh&RdbZ z464x%-2z5|p7MkgebhY}Uz(th^QX`HguU&2*0tym%vv>FSU`)l1JV?m;E)2!?(!=B>5rpe!}aEh}u z>_%|+p8dmit5teMprr5}FsFq$^&Ev>RKJw42R<6|%xj;z*f2t&AbItV!5vOJBmTyD z9VQ!t+lcv7`F~!NDnoCAU2_Ur^I{UlPqNy>iA?+}u)@}tA-<7BD};=)Cphd@*(p$e zI8k8ZU@v}nY$Os+yr~kTf)5q;oXU<~Bm)$CUS$oJQ&|z7qiWj_cPQk9#N=wWTj!MM z_?k^&E+%gwE24nj_#JT%mjzR@SNNV@!JalrqWX zpQ&vliW9lGl8RECy{ke;K+txAExbJazzTJOi+!WSXnoz!0Q^XnB_X|*QgT*g8}}HS zQBL7XDjaTHz5h5tq&UxH8MRNQ9F;rL*^0z5nm5Ol+ogavev|s~k#HE^Xuv zpYa&Ld79|c*`J8g(vZ&~crN&iRRm>f*(HEy7((jeOsFVci!fQ`GZw1H&ljIK2v$JG z;A>XFc~CL>C+03*r?A~Vcue3MDtmCg*oj)}WLwvwf>Gsgq|F_n!p&5Ze7ZIr`-@gf z&(2Dm*(WXnnB#zz3eSs5{~%18*+YcjD2+Wz{+o$Yb>0TbEu7KLk0U%IiVQMAZVSywb9*8Oe;MC&sEGX)5rH=d)L+ zgAppD6$kT6hzTd!FF#g{8qNnH9i}2@e)z~%UY9+5?bcGp$JswspnzKe_`iY?BIZ62 zlr$myFd5kS3?}Dk*8s+VT?L+&VVGmh>amFP{D%)sc&^9Vm@cgkkB;T@1-}=fGpuNj z4l{MJfy(`v&DWzSGANI-jzvKH$#5pdxB913b$FG!W-aX&G1flo6tlpwmpQADJ=hL+ z0al@Q5#d6dG+u`IDp2wpXF=Ij;2u8{@Cw?;0!SsOVc=mTN?JL7REX+r@;WNIM40{% zg`8n`;QG~kv0w?*_T&1X6{S=*ZE-pKP|5Onz{MbFlL7559ark37lZT2(Xol*`isDv zL#d=YX|pH5fe2s3Na*ks@>*h~sQwMxNQ{c0WU7hRWbsj0_Tp z2~|<}rv){31Q&973Ap%AJ7GUn=otGeWg=P;n^C*J3#L;FEeg)(u!H18Iee-Hwg$|p zv1<0!DvJ8H#(pJijomJk4Zy0PtkR*-mXTXjxF-sYf<0U0tq!OFzF)}4q7HVpsjUa- z1|?G9y>{UW;yQE|l}1L5$>PdN4!Out)wpKGJfapSg!vs6gtKajt1H-kH-9YWDIS)F zIOJ$xs0u=?Bj^IP8C<8 z=<~waih8qGP8=oRIN(JT5uyT!4>;JLmx{%p?mLpkXt7riK3PI}H&&;x4*)K%yMVTg ztf!Damic@VTnMa9caQ%*=JnSCUfj)|M0kVQgl9ss48zqBTbB20Tufrv4*Cf|ZfII5 z|FLK+u82X!sylc?DgT(qIID_{Q1BUnsK1my(#~BAetlfKrx~dncslj>2~{YWJc$U; zk665v4_{v)88WsVXgdJN^T2RiPr)9`m>u{zr4Kex?TXTmlpWp&6vTycf0S4Y6|6Oj z<2MW{sZy^=3NWxw(W*4Hf;8%gyIs`^IY5u5NYH#5xkqiIO5c=GR|*W3T6Qa&T=0%5 zNi^ODxLL>UB<~=XuSW{`{QaD$0^qQAmhN1M$~mCTM>!WOl$ijJ*%u1!Nwc@91njMO z=Aq-e&DAbC|M$Jbq*23FM9~J(K7QCvyyW{<6buN<(#PIT?EJUAw~Lm=>Hf9f zKS)rNdTLoi2$^x6!!3*=0Xn?cfI|@2uQ1vbs8DU(-a!sdho`Xtv40U|Q0cJu&)Ub# K*6%i@&-&jRcsoV_ diff --git a/runTests.server.luau b/runTests.server.luau new file mode 100644 index 0000000..168d462 --- /dev/null +++ b/runTests.server.luau @@ -0,0 +1,3 @@ +require(script.Parent.TestEZ).TestBootstrap:run({ + game:GetService("ServerScriptService").Test +}) \ No newline at end of file diff --git a/src/Index/Client/Index.luau b/src/Index/Client/Index.luau index d0002fd..0ee1b52 100644 --- a/src/Index/Client/Index.luau +++ b/src/Index/Client/Index.luau @@ -1,5 +1,6 @@ --!strict --!native +--!optimize 2 local Client = {} Client.__index = Client @@ -10,12 +11,17 @@ local ClientProcess = require(script.Parent.ClientProcess) local Assert = require(Util.Assert) local Key = require(Util.Key) local Serdes = require(Util.Serdes) +local Buffer = require(Util.Buffer) function Client.new(Identifier: string) local self = setmetatable({}, Client) - self.id = Serdes(Identifier) + self._buffer = Buffer.new() + self._buffer:writeu8(Serdes(Identifier)) + self.id = Buffer.convert(self._buffer:build()) self.fn = {} + self.IsConnected = false ClientProcess.add(self.id, Identifier) + self._buffer:remove() return self end @@ -30,6 +36,7 @@ end function Client:Connect(callback: (args: any) -> ()): string local key = tostring(Key()) table.insert(self.fn, key) + self.IsConnected = #self.fn > 0 ClientProcess.addCallback(self.id, key, callback) return key end @@ -37,6 +44,7 @@ end function Client:Once(callback: (args: any) -> ()): string local key = tostring(Key()) table.insert(self.fn, key) + self.IsConnected = #self.fn > 0 ClientProcess.addCallback(self.id, key, function(...) self:Disconnect(key) task.spawn(callback, ...) @@ -53,15 +61,17 @@ function Client:Wait() end function Client:DisconnectAll() - for idx, key: string in self.fn do - ClientProcess.removeCallback(self.id, key) - table.remove(self.fn, idx) + for _, key: string in self.fn do + self:Disconnect(key) end end -function Client:Disconnect(key: string) +function Client:Disconnect(key: string): boolean Assert(typeof(key) == "string", "Key must be a string type.") ClientProcess.removeCallback(self.id, key) + table.remove(self.fn, table.find(self.fn, key)) + self.IsConnected = #self.fn > 0 + return table.find(self.fn, key) == nil end function Client:Destroy() diff --git a/src/Index/Server/Index.luau b/src/Index/Server/Index.luau index 4811d1f..8945377 100644 --- a/src/Index/Server/Index.luau +++ b/src/Index/Server/Index.luau @@ -20,6 +20,7 @@ function Server.new(Identifier: string, rateLimit: Type.rateLimitArg?) self._buffer:writeu8(Serdes(Identifier)) self.id = Buffer.convert(self._buffer:build()) self.fn = {} + self.IsConnected = false ServerProcess.add(self.id, Identifier, rateLimit or { maxEntrance = 200, interval = 2 }) self._buffer:remove() return self @@ -50,12 +51,14 @@ function Server:Connect(callback: (plyer: Player, args: any) -> ()): string local key = tostring(Key()) table.insert(self.fn, key) ServerProcess.addCallback(self.id, key, callback) + self.IsConnected = #self.fn > 0 return key end function Server:Once(callback: (plyer: Player, args: any) -> ()): string local key = tostring(Key()) table.insert(self.fn, key) + self.IsConnected = #self.fn > 0 ServerProcess.addCallback(self.id, key, function(...) self:Disconnect(key) task.spawn(callback, ...) @@ -72,15 +75,17 @@ function Server:Wait() end function Server:DisconnectAll() - for idx, key: string in self.fn do - ServerProcess.removeCallback(self.id, key) - table.remove(self.fn, idx) + for _, key: string in self.fn do + self:Disconnect(key) end end -function Server:Disconnect(key: string) +function Server:Disconnect(key: string): boolean Assert(typeof(key) == "string", "Key must be a string type.") ServerProcess.removeCallback(self.id, key) + table.remove(self.fn, table.find(self.fn, key)) + self.IsConnected = #self.fn > 0 + return table.find(self.fn, key) == nil end function Server:Destroy() diff --git a/src/Index/Server/ServerProcess.luau b/src/Index/Server/ServerProcess.luau index 2b85e38..2cf1c17 100644 --- a/src/Index/Server/ServerProcess.luau +++ b/src/Index/Server/ServerProcess.luau @@ -132,7 +132,6 @@ function ServerProcess.add(Identifier: string, originId: string, ratelimit: Type end function ServerProcess.addCallback(Identifier: string, key: string, callback) - print(serverCallback, Identifier) serverCallback[Identifier][key] = callback end diff --git a/src/Index/Signal/init.luau b/src/Index/Signal/init.luau index 2e946e9..5b7387c 100644 --- a/src/Index/Signal/init.luau +++ b/src/Index/Signal/init.luau @@ -73,7 +73,7 @@ end function Signal:InvokeTo(signal: string, key: string, ...: any): () if not Signals[signal] then return end - return Signal.Invoke(Signals[signal], ...) + return Signal.Invoke(Signals[signal], key, ...) end function Signal:Destroy(): () diff --git a/src/Index/init.luau b/src/Index/init.luau index b4aa005..c8319ed 100644 --- a/src/Index/init.luau +++ b/src/Index/init.luau @@ -1,4 +1,5 @@ --!strict +--!optimize 2 local Index = {} local RunService = game:GetService("RunService") diff --git a/src/init.luau b/src/init.luau index 23eae2f..231f605 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,7 +1,8 @@ -- Warp Library (@Eternity_Devs) --- version 1.0.6 +-- version 1.0.8 --!strict --!native +--!optimize 2 local Index = require(script.Index) return { diff --git a/test.project.json b/test.project.json new file mode 100644 index 0000000..5484f59 --- /dev/null +++ b/test.project.json @@ -0,0 +1,30 @@ +{ + "name": "warp-test", + "tree": { + "$className": "DataModel", + "ServerScriptService": { + "$className": "ServerScriptService", + "Test": { + "$path": "test" + } + }, + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "Warp": { + "$path": "src" + } + }, + "TestService": { + "$className": "TestService", + "$properties": { + "ExecuteWithStudioRun": true + }, + "TestEZ": { + "$path": "TestEZ" + }, + "run": { + "$path": "runTests.server.luau" + } + } + } +} \ No newline at end of file diff --git a/test/init.spec.luau b/test/init.spec.luau new file mode 100644 index 0000000..68ef833 --- /dev/null +++ b/test/init.spec.luau @@ -0,0 +1,166 @@ +return function() + local Warp = require(game:GetService("ReplicatedStorage").Warp) + + describe("Warp.Server", function() + it("should be able to create a new server event", function() + local test = Warp.Server("Test") + expect(test).to.be.ok() + end) + + it("should be able to create a new server event with ratelimit configuration", function() + local test = Warp.Server("Test", { + maxEntrance = 10, + interval = 1, + }) + expect(test).to.be.ok() + end) + end) + + describe("Warp.fromServerArray", function() + it("should be able to create a new server event with arrays", function() + local test = Warp.fromServerArray({ + "Test1", + "Test2", + }) + expect(test).to.be.ok() + expect(test.Test1).to.be.ok() + expect(test.Test2).to.be.ok() + end) + + it("should be able to create a new server event with arrays & ratelimit configuration", function() + local test = Warp.fromServerArray({ + "Test1", + "Test2", + ["Test3"] = { + maxEntrance = 10, + interval = 0.75, + }, + }) + expect(test).to.be.ok() + expect(test.Test1).to.be.ok() + expect(test.Test2).to.be.ok() + expect(test.Test3).to.be.ok() + end) + end) + + describe("Event.Connect", function() + it("should be able to connect the event", function() + local test = Warp.Server("Test") + test:Connect(function() end) + expect(test.IsConnected).to.be.ok() + end) + end) + + describe("Multi Event.Connect", function() + it("should be able to multiple connect the event", function() + local test = Warp.Server("Test") + test:Connect(function() end) + test:Connect(function() end) + expect(test.IsConnected).to.be.ok() + end) + end) + + describe("Event.DisconnectAll", function() + it("should be able to disconnect all the event connections", function() + local test = Warp.Server("Test") + test:DisconnectAll() + expect(#test.fn).to.equal(0) + end) + end) + + describe("Event.Disconnect", function() + it("should be able to disconnect the event connection", function() + local test = Warp.Server("Test") + local connection = test:Connect(function() end) + expect(test:Disconnect(connection)).to.be.ok() + end) + end) + + describe("Warp.Signal", function() + it("should be able to create a new signal", function() + local test = Warp.Signal("Test") + expect(test).to.be.ok() + end) + end) + + describe("Warp.fromSignalArray", function() + it("should be able to create a new signal with arrays", function() + local test = Warp.fromSignalArray({ + "Test1", + "Test2" + }) + expect(test).to.be.ok() + expect(test.Test1).to.be.ok() + expect(test.Test2).to.be.ok() + end) + end) + + describe("Signal.Connect", function() + it("should be able to connect the signal", function() + local test = Warp.Signal("Test") + expect(test:Connect(function() end)).to.be.ok() + test:DisconnectAll() + end) + end) + + describe("Multi Signal.Connect", function() + it("should be able to multiple connect the signal", function() + local test = Warp.Signal("Test") + expect(test:Connect(function() end)).to.be.ok() + expect(test:Connect(function() end)).to.be.ok() + test:DisconnectAll() + end) + end) + + describe("Signal.Fire", function() + it("should be able to fire the signal", function() + local test = Warp.Signal("Test") + test:Once(function(arg) + expect(arg).to.equal("hello world!") + end) + test:Fire("hello world!") + end) + end) + + describe("Signal.Invoke", function() + it("should be able to invoke the signal", function() + local test = Warp.Signal("Test") + local connection = test:Connect(function(arg) + if arg ~= "test" then + return + end + return "hello world!" + end) + local receive = test:Invoke(connection, "test") + expect(receive).to.equal("hello world!") + end) + end) + + describe("Signal.InvokeTo", function() + it("should be able to invoke to a signal", function() + local test = Warp.Signal("Test") + local test2 = Warp.Signal("Test2") + local connection = test2:Connect(function(arg) + if arg ~= "test" then + return + end + return "hello world!" + end) + local receive = test:InvokeTo("Test2", connection, "test") + expect(receive).to.equal("hello world!") + end) + end) + + describe("Signal.Wait", function() + it("should be able to wait for the signal", function() + local test = Warp.Signal("Test") + test:Connect(function() end) + task.spawn(function() + local time = test:Wait() + expect(time).to.be.ok() + expect(time).to.be.a("number") + end) + test:Fire() + end) + end) +end \ No newline at end of file diff --git a/wally.toml b/wally.toml index fda93f9..15522a3 100644 --- a/wally.toml +++ b/wally.toml @@ -1,6 +1,6 @@ [package] name = "imezx/warp" -version = "1.0.7" +version = "1.0.8" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" license = "MIT"