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 9e7c5aa..d9a10c2 100644 Binary files a/Warp.rbxm and b/Warp.rbxm differ 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"