test: improve test suite

This commit is contained in:
littensy 2025-05-11 04:56:46 -07:00
parent a53683d59e
commit 4e5f656158
4 changed files with 395 additions and 123 deletions

View file

@ -9,4 +9,4 @@ rojo = "rojo-rbx/rojo@7.5.1"
stylua = "johnnymorganz/stylua@2.1.0" stylua = "johnnymorganz/stylua@2.1.0"
selene = "Kampfkarren/selene@0.28.0" selene = "Kampfkarren/selene@0.28.0"
luau-lsp = "johnnymorganz/luau-lsp@1.44.1" luau-lsp = "johnnymorganz/luau-lsp@1.44.1"
lute = "luau-lang/lute@0.1.0-nightly.20250506" lute = "luau-lang/lute@0.1.0-nightly.20250511"

View file

@ -1,11 +1,12 @@
local suite = require("@tests/suite") local suite = require("@tests/suite")
local finish = suite.finish
if not table.isfrozen(_G) then if not table.isfrozen(_G) then
_G.__DEV__ = true _G.__DEV__ = true
end end
require("@pkgs/charm/tests/spec") require("@pkgs/charm/tests/spec")
finish() suite.finish({
colors = true,
verbose = false,
})

View file

@ -1,7 +1,5 @@
local suite = require("./suite") local suite = require("./suite")
local finish = suite.finish
if not script then if not script then
return return
end end
@ -12,4 +10,7 @@ require(script.Parent.CharmSync.spec)
require(script.Parent.ReactCharm.spec) require(script.Parent.ReactCharm.spec)
require(script.Parent.VideCharm.spec) require(script.Parent.VideCharm.spec)
finish() suite.finish({
colors = false,
verbose = true,
})

View file

@ -1,161 +1,431 @@
type Config = {
colors: boolean?,
verbose: boolean?,
}
type Description = {
status: number,
name: string,
only: boolean,
skip: boolean,
parent: Description?,
children: { Description | Test },
passes: number,
fails: number,
skips: number,
runtime: number,
}
type Test = {
status: number,
name: string,
only: boolean,
skip: boolean,
parent: Description?,
children: nil,
run: () -> (),
result: unknown?,
line: number?,
runtime: number,
}
local options = {
colors = true,
verbose = false,
}
local color = { local color = {
white = function(text: string | number) white = function(text: string)
return `\27[37;1m{text}\27[0m` return if options.colors then `\27[37;1m{text}\27[0m` else text
end, end,
gray = function(text: string | number) gray = function(text: string)
return `\27[90;1m{text}\27[0m` return if options.colors then `\27[90;1m{text}\27[0m` else text
end, end,
green = function(text: string | number) green = function(text: string)
return `\27[32;1m{text}\27[0m` return if options.colors then `\27[32;1m{text}\27[0m` else text
end, end,
red = function(text: string | number) red = function(text: string)
return `\27[31;1m{text}\27[0m` return if options.colors then `\27[31;1m{text}\27[0m` else text
end, end,
blue = function(text: string | number) blue = function(text: string)
return `\27[34;1m{text}\27[0m` return if options.colors then `\27[34;1m{text}\27[0m` else text
end, end,
} }
if script then local PENDING = 0
local function prepend(prefix: string?): (string | number) -> string local PASS = 1
return function(text: string | number): string local FAIL = 2
return if prefix then `{prefix} {text}` else `{text}` local SKIP = 3
end local ERROR = 4
local tests: { Test } = {}
local descriptions: { Description } = {}
local parent: Description = {
status = PENDING,
name = "root",
only = false,
skip = false,
children = {},
passes = 0,
fails = 0,
skips = 0,
runtime = 0,
}
local function describe(name: string, run: () -> ()): Description
local this: Description = {
status = PENDING,
name = name,
only = false,
skip = false,
parent = parent,
children = {},
passes = 0,
fails = 0,
skips = 0,
runtime = 0,
}
local prevDescription = parent
parent = this
run()
parent = prevDescription
table.insert(parent.children, this)
table.insert(descriptions, this)
return this
end end
color.white = prepend() local function test(name: string, run: () -> ()): Test
color.gray = prepend() local this: Test = {
color.green = prepend(utf8.char(0x1F7E2)) -- green circle status = PENDING,
color.red = prepend(utf8.char(0x1F534)) -- red circle name = name,
color.blue = prepend(utf8.char(0x1F535)) -- blue circle only = false,
end skip = false,
parent = parent,
run = run,
runtime = 0,
}
local WALL = color.gray("│") table.insert(parent.children, this)
local PASS = color.green("PASS") .. WALL table.insert(tests, this)
local FAIL = color.red("FAIL") .. WALL
local SKIP = color.blue("SKIP") .. WALL
local ERROR = color.red("ERROR") .. WALL
local results: { string } = {} return this
local stack: { string } = {}
local errors: { string } = {}
local skipDepth = 0
local skipCount = 0
local testsOk = 0
local testsFailed = 0
local testsSkipped = 0
local filesOk = 0
local filesFailed = 0
local start = os.clock()
local function log(result: string, index: number?)
if index then
table.insert(results, index, result)
else
table.insert(results, result)
end
end end
local function skip() local function skip()
skipDepth += 1 parent.skip = true
end end
local function test(name: string, callback: () -> ()) local function only()
local path = color.gray(table.concat(stack, " > ")) parent.only = true
local line = color.red("-1:") parent.only = true
end
if skipDepth > 0 then local function testSkip(name: string, run: () -> ()): Test
testsSkipped += 1 local this = test(name, run)
skipCount += 1 this.skip = true
return this
end
local function testOnly(name: string, run: () -> ()): Test
local this = test(name, run)
this.only = true
parent.only = true
return this
end
local function describeSkip(name: string, run: () -> ()): Description
local this = describe(name, run)
this.skip = true
return this
end
local function describeOnly(name: string, run: () -> ()): Description
local this = describe(name, run)
this.only = true
parent.only = true
return this
end
local function isOnly(test: Test): boolean
if not parent.only or test.only then
return true
end
local description = test.parent
while description and description ~= parent do
if description.only then
return true
elseif description.skip then
return false
end
description = description.parent
end
return false
end
local function isSkip(test: Test): boolean
if test.only then
return false
elseif test.skip then
return true
end
local description = test.parent
while description do
if description.only and description ~= parent then
return false
elseif description.skip then
return true
end
description = description.parent
end
return false
end
local function runTests()
for _, test in tests do
if isSkip(test) or not isOnly(test) then
test.status = SKIP
continue
end
local start = os.clock()
local success = xpcall(test.run, function(result)
test.status = FAIL
test.result = result
test.line = debug.info(3, "l")
end)
test.runtime = os.clock() - start
if success then
test.status = PASS
end
end
end
local function updateDescription(description: Description)
if not description.children[1] then
description.status = PASS
return return
end end
table.insert(stack, name) description.status = SKIP
local success = xpcall(callback, function(err) for _, child in description.children do
line = color.red(`{debug.info(3, "l")}:`) if child.children then
table.insert(errors, `\n{ERROR} {path} > {line}{color.white(name)}\n{err}`) updateDescription(child)
end) description.passes += child.passes
description.fails += child.fails
elseif child.status == PASS then
description.passes += 1
elseif child.status == FAIL then
description.fails += 1
end
if success then -- Only count skips in direct children
testsOk += 1 if child.status == SKIP then
description.skips += 1
end
description.runtime += child.runtime
if child.status == FAIL then
description.status = FAIL
elseif description.status == SKIP and child.status == PASS then
description.status = PASS
end
end
end
local function getIndent(node: Description | Test): string
local depth = 0
local description = node.parent
while description and description ~= parent do
depth += 1
description = description.parent
end
return if depth > 0 then string.rep(" ", depth - 1) .. color.gray("· ") else ""
end
local function getPath(node: Description | Test): string
local path = { "" }
local description = node.parent
while description and description ~= parent do
table.insert(path, 1, description.name)
description = description.parent
end
return table.concat(path, " > ")
end
local function greenOrRed(text: string, good: boolean): string
return if good then color.green(text) else color.red(text)
end
local function colorStatus(status: number): string
local wall = color.gray("│")
if status == PASS then
return color.green("PASS") .. wall
elseif status == FAIL then
return color.red("FAIL") .. wall
elseif status == SKIP then
return color.blue("SKIP") .. wall
else else
testsFailed += 1 return color.red("ERROR") .. wall
log(`{FAIL} {path} {color.gray(">")} {line}{color.white(name)}`)
end
table.remove(stack)
end
local function describe(name: string, callback: () -> ())
local path = if #stack > 0 then color.gray(table.concat(stack, " > ")) .. color.gray(" > ") else ""
local index = #results + 1
local start = os.clock()
local initialTestsOk = testsOk
local initialTestsFailed = testsFailed
local initialSkipDepth = skipDepth
skipCount = 0
table.insert(stack, name)
callback()
table.remove(stack)
skipDepth = initialSkipDepth
local skippedEveryTest = initialTestsOk == testsOk and initialTestsFailed == testsFailed and skipCount > 0
local passedEveryTest = testsFailed == initialTestsFailed
local millis = string.format("%.2fms", (os.clock() - start) * 1000)
local skips = if skipCount > 0 then color.blue(` ({skipCount} skipped) `) else " "
if skippedEveryTest then
filesOk += 1
log(`{SKIP} {path .. color.white(name)}{skips}`, index)
elseif passedEveryTest then
filesOk += 1
log(`{PASS} {path .. color.white(name)}{skips}{color.gray(millis)}`, index)
else
filesFailed += 1
log(`{FAIL} {path .. color.white(name)}{skips}{color.gray(millis)}`, index)
end end
end end
local function finish() local function report()
local tests = testsOk + testsFailed + testsSkipped
local files = filesOk + filesFailed
local failed = testsFailed + filesFailed > 0
local millis = math.round((os.clock() - start) * 1000)
local output = {} local output = {}
table.insert(output, color.blue("Test Results\n")) local function reportDescription(description: Description)
local name = color.white(description.name)
local text = `{colorStatus(description.status)} {getIndent(description)}{name}`
for _, result in results do if description.fails > 0 then
table.insert(output, result) text ..= " " .. color.red(`({description.fails})`)
end end
local redOrGreen: (string | number) -> string = if failed then color.red else color.green if description.skips > 0 then
text ..= " " .. color.blue(`({description.skips} skipped)`)
elseif description.fails == 0 then
text ..= " " .. color.gray(`({string.format("%.2fms", description.runtime * 1000)})`)
end
table.insert(output, text)
end
local function reportTest(test: Test, reportError: boolean?)
if test.status ~= FAIL and not options.verbose then
return
end
local line = color.red(`{test.line}:`)
if reportError then
local name = color.white(test.name)
local path = color.gray(getPath(test))
table.insert(output, `{colorStatus(ERROR)} {path}{line}{name}`)
table.insert(output, `{test.result}`)
else
local name = color.gray(test.name)
local indent = getIndent(test)
if test.status == FAIL then
table.insert(output, `{colorStatus(FAIL)} {indent}{line}{name}`)
else
table.insert(output, `{colorStatus(test.status)} {indent}{name}`)
end
end
end
local function reportNode(node: Description | Test)
if not node.children then
return reportTest(node :: Test)
end
reportDescription(node)
for _, child in node.children do
reportNode(child)
end
end
local function reportSummary()
local describePasses = 0
local describeFails = 0
for _, description in descriptions do
if description.status == PASS then
describePasses += 1
elseif description.status == FAIL then
describeFails += 1
end
end
local describes =
`{greenOrRed(`{describePasses} passed`, describeFails == 0)} {color.gray(`({#descriptions})`)}`
local tests = `{greenOrRed(`{parent.passes} passed`, parent.fails == 0)} {color.gray(`({#tests})`)}`
local duration = `{color.blue(`{string.format("%.2fms", parent.runtime * 1000)}`)}`
table.insert(output, "") table.insert(output, "")
table.insert(output, `Test Files {redOrGreen(`{filesOk} passed`)} {color.gray(`({files})`)}`) table.insert(output, `Describe {describes}`)
table.insert(output, ` Tests {redOrGreen(`{testsOk} passed`)} {color.gray(`({tests})`)}`) table.insert(output, ` Tests {tests}`)
table.insert(output, ` Duration {color.blue(`{millis}ms`)}`) table.insert(output, `Duration {duration}`)
table.insert(output, "")
end
print("\n" .. table.concat(output, "\n") .. "\n") local function reportErrors()
if parent.fails == 0 then
return
end
if failed then for _, test in tests do
print(`{color.red(`{testsFailed} errors:`)}\n{table.concat(errors, "\n")}\n`) if test.status == FAIL then
error("Tests failed") reportTest(test, true)
table.insert(output, "")
end end
end end
end
table.insert(output, "")
table.insert(output, color.blue("Test Results"))
table.insert(output, "")
for _, child in parent.children do
reportNode(child)
end
reportSummary()
reportErrors()
print(table.concat(output, "\n"))
if parent.fails > 0 then
error(color.red(`{parent.fails} test(s) failed`))
end
end
local function finish(config: Config?)
if config then
options.colors = if config.colors ~= nil then config.colors else options.colors
options.verbose = if config.verbose ~= nil then config.verbose else options.verbose
end
runTests()
updateDescription(parent)
report()
end
local testImpl = setmetatable({
skip = testSkip,
only = testOnly,
}, {
__call = function(_, name: string, run: () -> ())
return test(name, run)
end,
})
local describeImpl = setmetatable({
skip = describeSkip,
only = describeOnly,
}, {
__call = function(_, name: string, run: () -> ())
return describe(name, run)
end,
})
return { return {
test = test, test = testImpl,
describe = describe, describe = describeImpl,
skip = skip, skip = skip,
only = only,
finish = finish, finish = finish,
} }