diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c47f0c..54ccbb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/Source/Testable/Coverage.luau b/Source/Testable/Coverage.luau index e254d8d..85b699b 100644 --- a/Source/Testable/Coverage.luau +++ b/Source/Testable/Coverage.luau @@ -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 @@ -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 diff --git a/Source/Testable/TestBootstrap.luau b/Source/Testable/TestBootstrap.luau index 552aea4..ddb62f0 100644 --- a/Source/Testable/TestBootstrap.luau +++ b/Source/Testable/TestBootstrap.luau @@ -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 diff --git a/Source/Testable/init.luau b/Source/Testable/init.luau index 29026a4..bd41eb8 100644 --- a/Source/Testable/init.luau +++ b/Source/Testable/init.luau @@ -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 diff --git a/VERSION b/VERSION index 524cb55..45a1b3f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.1 +1.1.2 diff --git a/wally.toml b/wally.toml index 6965f45..b51daa2 100644 --- a/wally.toml +++ b/wally.toml @@ -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"