Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
58c710a
Add subprocess tests for fail() function
horsenuggets Jan 11, 2026
b017b5c
Add .spec suffix to test file headers
horsenuggets Jan 11, 2026
d542ed0
Add claude-md-luau submodule
horsenuggets Jan 12, 2026
679e448
Update copyright year to 2026
horsenuggets Jan 12, 2026
99229ca
Use lowercase project name in README
horsenuggets Jan 12, 2026
5a7619d
Add VS Code workspace configuration
horsenuggets Jan 12, 2026
289d9e0
Remove header from root init.luau
horsenuggets Jan 13, 2026
68ae248
Update claude-md-luau submodule
horsenuggets Jan 13, 2026
b55f317
Use luau-cicd submodule for CI/CD scripts
horsenuggets Jan 13, 2026
f1304d0
Bump version to 0.1.0
horsenuggets Jan 13, 2026
e829db5
Refactor CI/CD workflow for new release process (#15)
horsenuggets Jan 13, 2026
a744ab9
Remove unsupported block_newline_gaps from stylua config (#16)
horsenuggets Jan 13, 2026
5635f83
Remove diff-check from release workflow (#17)
horsenuggets Jan 13, 2026
882f06b
Add diff-check back to release workflow (#18)
horsenuggets Jan 14, 2026
006fe54
Update CHANGELOG.md to new format (#20)
horsenuggets Jan 16, 2026
148f77f
Update submodules to latest (#22)
horsenuggets Jan 19, 2026
af1ed57
Update submodules (#23)
horsenuggets Mar 18, 2026
9528440
Standardize CI workflows (#26)
horsenuggets Mar 27, 2026
455f10e
Capitalize Git as a proper noun (#24)
horsenuggets Mar 27, 2026
0ebd722
Bump toolchain versions and sync with luau-package-template (#27)
horsenuggets Mar 27, 2026
f314571
Add code coverage support and dictionary syntax for test roots (#28)
horsenuggets Mar 30, 2026
450e1d7
Add Roblox cloud E2E tests and fix Roblox compatibility (#30)
horsenuggets Mar 31, 2026
41cc362
Run Roblox cloud E2E tests in CI (#32)
horsenuggets Mar 31, 2026
a7af71a
Add coverage unit tests and bump to 1.0.0 (#33)
horsenuggets Mar 31, 2026
1ea9e21
Update submodules (#35)
horsenuggets Mar 31, 2026
dda2dcb
Use SSH URLs for submodules (#36)
horsenuggets Mar 31, 2026
c3ea6d9
Fix infinite loop on modules with __call metatables (#37)
horsenuggets Mar 31, 2026
ba6d6f7
Fix lint errors for dual-platform Roblox/Lune support (#39)
horsenuggets Mar 31, 2026
966cecf
Update submodules (#40)
horsenuggets Apr 1, 2026
0105583
Add file-level coverage via script instances (#41)
horsenuggets Apr 1, 2026
d8e4755
Bump version to 1.1.0 (#42)
horsenuggets Apr 1, 2026
cb814a8
Fix coverage discovery of init.luau modules (#44)
horsenuggets Apr 1, 2026
7823c44
Support script instances for test and coverage roots (#46)
horsenuggets Apr 1, 2026
e9546e2
Merge remote-tracking branch 'origin/release' into pre-release
horsenuggets Apr 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 1.1.2
- Added script instance support for test roots (`Testable.run(script.Parent.Tests)`)
- Added automatic `.spec.luau` file discovery from directory script instances
- Added support for both single script and dictionary syntax in `CoverageRoots`

## 1.1.1
- Changed `CoverageRoots` to accept a single script instance instead of a dictionary
- Fixed coverage discovery of `init.luau` modules (directories with init.luau were skipped)
Expand Down
91 changes: 63 additions & 28 deletions Source/Testable/Coverage.luau
Original file line number Diff line number Diff line change
Expand Up @@ -196,50 +196,85 @@ local function collectFileCoverage(scriptInstance: any): ({ FunctionCoverage },
end

--[[
Resolves a CoverageRoots script instance into a flat list of
modules. CoverageRoots is a single script instance pointing to a
source directory (e.g., script.Parent.Source). All .luau modules
under the directory are discovered recursively.
Resolves a single script instance into discovered modules. Handles
directories (recursively discovers .luau files), directories with
init.luau (treated as a module), and individual file scripts.

@param root - Script instance pointing to the source directory
@return scripts - Array of { Name, Script } entries
@param scriptInstance - Script instance to resolve
@param results - Array to accumulate { Name, Script } entries
@param seen - Set of already-processed paths for deduplication
]]
function Coverage.resolveRoots(root: any): { { Name: string, Script: any } }
local results: { { Name: string, Script: any } } = {}
local seen: { [string]: boolean } = {}
local rootPath = tostring(root)

local function resolveScript(
scriptInstance: any,
name: string,
results: { { Name: string, Script: any } },
seen: { [string]: boolean }
)
if not IS_LUNE then
return results
return
end

local fs = require("@lune/fs")
local scriptPath = tostring(scriptInstance)

if fs.isDir(rootPath) then
-- Check if the root itself is a module (has init.luau)
local initPath = rootPath .. "/init.luau"
if fs.isDir(scriptPath) then
-- Check if the directory itself is a module (has init.luau)
local initPath = scriptPath .. "/init.luau"
if fs.isFile(initPath) then
if not isExcluded(getDisplayName(root)) then
seen[rootPath] = true
if not seen[scriptPath] and not isExcluded(name) then
seen[scriptPath] = true
table.insert(results, {
Name = getDisplayName(root),
Script = root,
Name = name,
Script = scriptInstance,
})
end
end

-- Discover child modules
discoverModules(root, results, seen)
elseif fs.isFile(rootPath) or fs.isFile(rootPath .. ".luau") or fs.isFile(rootPath .. ".lua") then
discoverModules(scriptInstance, results, seen)
elseif fs.isFile(scriptPath) or fs.isFile(scriptPath .. ".luau") or fs.isFile(scriptPath .. ".lua") then
-- Individual file
if not isExcluded(getDisplayName(root)) then
seen[rootPath] = true
if not seen[scriptPath] and not isExcluded(name) then
seen[scriptPath] = true
table.insert(results, {
Name = getDisplayName(root),
Script = root,
Name = name,
Script = scriptInstance,
})
end
end
end

--[[
Resolves CoverageRoots into a flat list of modules. Accepts either
a single script instance or a dictionary of script instances.

Single script: script.Parent.Source
Dictionary: { Chalk = script.Parent.Source.Chalk, Utils = ... }

@param roots - Script instance or dictionary of script instances
@return scripts - Array of { Name, Script } entries
]]
function Coverage.resolveRoots(roots: any): { { Name: string, Script: any } }
local results: { { Name: string, Script: any } } = {}
local seen: { [string]: boolean } = {}

-- Detect if roots is a dictionary (table with string keys) or
-- a single script instance (userdata with Name/Parent)
if type(roots) == "table" then
-- Dictionary format: { Name = scriptInstance, ... }
local keys = {}
for key in pairs(roots) do
table.insert(keys, key)
end
table.sort(keys)

for _, name in keys do
resolveScript(roots[name], name, results, seen)
end
else
-- Single script instance
resolveScript(roots, getDisplayName(roots), results, seen)
end

return results
end
Expand All @@ -248,16 +283,16 @@ end
Collects coverage data for all resolved modules. Returns nil when
coverage is not enabled (LUNE_COVERAGE=0).

@param root - CoverageRoots script instance from config
@param roots - Script instance or dictionary of script instances
@return report - The coverage report, or nil if coverage is disabled
]]
function Coverage.collect(root: any): CoverageReport?
function Coverage.collect(roots: any): CoverageReport?
local hasApi = type(debug.iscoverageenabled) == "function"
if not hasApi or not debug.iscoverageenabled() then
return nil
end

local scripts = Coverage.resolveRoots(root)
local scripts = Coverage.resolveRoots(roots)
local files: { FileCoverage } = {}
local grandTotalHit = 0
local grandTotalExecutable = 0
Expand Down
74 changes: 68 additions & 6 deletions Source/Testable/TestBootstrap.luau
Original file line number Diff line number Diff line change
Expand Up @@ -142,18 +142,80 @@ function TestBootstrap:getModules(root: any): { any }
end

--[[
Gathers test modules from multiple root locations. Supports both
array format and dictionary format:
Checks if a filename is a spec file (.spec.luau, .spec.lua,
.Spec.luau, .Spec.lua).

Array: { { Name = "Test", Func = fn }, ... }
Dict: { Test = fn, Other = fn, ... }
@param filename - The filename to check
@return True if this is a spec file
]]
local function isSpecFile(filename: string): boolean
return filename:match("%.[sS]pec%.luau$") ~= nil or filename:match("%.[sS]pec%.lua$") ~= nil
end

--[[
Discovers all spec files from a script instance directory and
returns them as dictionary entries { Name = requireResult }.
Only available in Lune.

@param dirScript - Script instance pointing to a tests directory
@return Dictionary of { SpecName = requireResult }
]]
local function discoverSpecsFromDirectory(dirScript: any): { [string]: any }
if not IS_LUNE then
return {}
end

local fs = require("@lune/fs")
local dirPath = tostring(dirScript)
local specs = {}

local function walk(currentPath: string, parentScript: any)
local ok, entries = pcall(fs.readDir, currentPath)
if not ok then
return
end

@param roots - Array or dictionary of test roots
for _, entry in entries do
local fullPath = currentPath .. "/" .. entry

if fs.isDir(fullPath) then
walk(fullPath, parentScript[entry])
elseif isSpecFile(entry) then
local baseName = entry:gsub("%.[sS]pec%.luau$", ""):gsub("%.[sS]pec%.lua$", "")
local specName = baseName
local childScript = parentScript[baseName .. ".spec"]
local requireOk, result = pcall(require, childScript)
if requireOk and type(result) == "function" then
specs[specName] = result
end
end
end
end

walk(dirPath, dirScript)
return specs
end

--[[
Gathers test modules from multiple root locations. Supports
script instances, array format, and dictionary format:

Script: script.Parent.Tests (discovers .spec.luau files)
Array: { { Name = "Test", Func = fn }, ... }
Dict: { Test = fn, Other = fn, ... }

@param roots - Script instance, array, or dictionary of test roots
@return Combined array of all module descriptors found
]]
function TestBootstrap:getModulesFromMultipleRoots(roots: { any }): { any }
function TestBootstrap:getModulesFromMultipleRoots(roots: any): { any }
local modules = {}

-- If roots is a script instance (userdata), discover specs from it
if type(roots) ~= "table" then
local specs = discoverSpecsFromDirectory(roots)
roots = specs
end

-- Detect if roots is a dictionary (string keys) or array (integer keys)
local isDictionary = false
if #roots == 0 then
Expand Down
6 changes: 3 additions & 3 deletions Source/Testable/init.luau
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ local TextReporter = require("@self/Reporters/TextReporter")
@return results - The test results object
@return passed - Boolean indicating if all tests passed
]]
local function run(testRoots: { any }): (any, boolean)
local function run(testRoots: any): (any, boolean)
if not testRoots then
error("testRoots must be a non-empty table")
error("testRoots must be provided")
end
if #testRoots == 0 and next(testRoots) == nil then
if type(testRoots) == "table" and #testRoots == 0 and next(testRoots) == nil then
error("testRoots must be a non-empty table")
end

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.1.1
1.1.2
2 changes: 1 addition & 1 deletion wally.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "horsenuggets/testable"
description = "A Luau testing framework based off of TestEZ."
version = "1.1.1"
version = "1.1.2"
license = "MIT"
realm = "shared"
registry = "https://github.com/UpliftGames/wally-index"
Expand Down
Loading