Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
2eefaed
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
2 changes: 1 addition & 1 deletion .luaurc
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"aliases": {
"lune": "~/.lune/.typedefs/0.10.4-horse.14.4/"
"lune": "~/.lune/.typedefs/0.10.4-horse.14.5/"
},
"globals": [
"afterAll",
Expand Down
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.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)
- Added exclusion of `.story` and `.storybook` files from coverage

## 1.1.0
- Changed `CoverageRoots` to accept script instances instead of module tables for full-file coverage
- Added file-level coverage that includes local/unexported functions via `debug.getcoverage(script)`
Expand Down
177 changes: 84 additions & 93 deletions Source/Testable/Coverage.luau
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
Coverage

Collects code coverage data from instrumented Luau files using
debug.getcoverage. Accepts script instances in CoverageRoots and passes
them directly to debug.getcoverage for full-file coverage, including
local functions not visible from the module table. Coverage is enabled
by default in Lune; set LUNE_COVERAGE=0 to disable.
debug.getcoverage. Accepts a script instance pointing to a source
directory and recursively discovers all modules, passing each to
debug.getcoverage for full-file coverage including local functions.
Coverage is enabled by default in Lune; set LUNE_COVERAGE=0 to
disable.

--]]

Expand All @@ -20,6 +21,8 @@ local EXCLUDED_SUFFIXES = {
"%.client$",
"%.plugin$",
"%.spec$",
"%.story$",
"%.storybook$",
}

export type FunctionCoverage = {
Expand Down Expand Up @@ -74,58 +77,68 @@ local function getDisplayName(scriptInstance: any): string
end

--[[
Discovers all Luau files under a directory using the filesystem API.
Only available in Lune. Returns an array of child script references.
Recursively discovers all Luau modules under a directory using the
filesystem API. Handles both regular .luau files and directories
with init.luau (which represent a single module). Only available
in Lune.

@param dirScript - The script instance representing a directory
@return Array of child script references
@param results - Array to accumulate { Name, Script } entries
@param seen - Set of already-processed paths for deduplication
]]
local function discoverFromDirectory(dirScript: any): { any }
local function discoverModules(dirScript: any, results: { { Name: string, Script: any } }, seen: { [string]: boolean })
if not IS_LUNE then
return {}
return
end

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

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

for _, entry in entries do
local fullPath = currentPath .. "/" .. entry
local isDir = fs.isDir(fullPath)

if isDir then
-- Check for init.luau inside directory
local initPath = fullPath .. "/init.luau"
if fs.isFile(initPath) then
local childScript = parentScript[entry]
local name = getDisplayName(childScript)
if not isExcluded(name) then
table.insert(results, childScript)
end
end
-- Recurse into subdirectory
walk(fullPath, parentScript[entry])
elseif entry:match("%.luau$") or entry:match("%.lua$") then
local baseName = entry:gsub("%.luau$", ""):gsub("%.lua$", "")
if baseName == "init" then
continue
for _, entry in entries do
local fullPath = dirPath .. "/" .. entry
local isDir = fs.isDir(fullPath)

if isDir then
local initPath = fullPath .. "/init.luau"
if fs.isFile(initPath) then
-- Directory with init.luau is a module
local childScript = dirScript[entry]
local scriptPath = tostring(childScript)
if not seen[scriptPath] and not isExcluded(entry) then
seen[scriptPath] = true
table.insert(results, {
Name = entry,
Script = childScript,
})
end
if not isExcluded(baseName) then
local childScript = parentScript[baseName]
table.insert(results, childScript)
end
-- Recurse into subdirectory regardless (may contain
-- more modules alongside or inside the init module)
discoverModules(dirScript[entry], results, seen)
elseif entry:match("%.luau$") or entry:match("%.lua$") then
local baseName = entry:gsub("%.luau$", ""):gsub("%.lua$", "")
-- Skip init files (handled as directory modules above)
if baseName == "init" then
continue
end
if not isExcluded(baseName) then
local childScript = dirScript[baseName]
local scriptPath = tostring(childScript)
if not seen[scriptPath] then
seen[scriptPath] = true
table.insert(results, {
Name = baseName,
Script = childScript,
})
end
end
end
end

walk(dirPath, dirScript)
return results
end

--[[
Expand Down Expand Up @@ -183,71 +196,49 @@ local function collectFileCoverage(scriptInstance: any): ({ FunctionCoverage },
end

--[[
Checks if a script instance points to a directory by trying to
read it as a directory with the filesystem API.

@param scriptInstance - The script instance to check
@return True if the script points to a directory
]]
local function isDirectory(scriptInstance: any): boolean
if not IS_LUNE then
return false
end
local fs = require("@lune/fs")
local path = tostring(scriptInstance)
return fs.isDir(path)
end

--[[
Resolves CoverageRoots into a flat list of script instances.
CoverageRoots uses dictionary syntax: { Name = scriptInstance, ... }
where each root is a script instance pointing to either a file or
a directory.

For directory roots, recursively discovers all .luau files using the
filesystem API. For file roots, uses the script directly.
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.

@param roots - Dictionary of { Name = scriptInstance } entries
@param root - Script instance pointing to the source directory
@return scripts - Array of { Name, Script } entries
]]
function Coverage.resolveRoots(roots: { [string]: any }): { { Name: string, Script: any } }
function Coverage.resolveRoots(root: any): { { Name: string, Script: any } }
local results: { { Name: string, Script: any } } = {}
local seen: { [string]: boolean } = {}
local rootPath = tostring(root)

-- Sort keys alphabetically for deterministic order
local keys = {}
for key in pairs(roots) do
table.insert(keys, key)
if not IS_LUNE then
return results
end
table.sort(keys)

for _, name in keys do
local root = roots[name]
local rootPath = tostring(root)
local fs = require("@lune/fs")

if isDirectory(root) then
-- Directory: recursively discover all .luau files
local scripts = discoverFromDirectory(root)
for _, scriptInstance in scripts do
local scriptPath = tostring(scriptInstance)
if not seen[scriptPath] then
seen[scriptPath] = true
table.insert(results, {
Name = getDisplayName(scriptInstance),
Script = scriptInstance,
})
end
end
else
-- Individual file
if not seen[rootPath] and not isExcluded(getDisplayName(root)) then
if fs.isDir(rootPath) then
-- Check if the root itself is a module (has init.luau)
local initPath = rootPath .. "/init.luau"
if fs.isFile(initPath) then
if not isExcluded(getDisplayName(root)) then
seen[rootPath] = true
table.insert(results, {
Name = getDisplayName(root),
Script = root,
})
end
end

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

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

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

local scripts = Coverage.resolveRoots(roots)
local scripts = Coverage.resolveRoots(root)
local files: { FileCoverage } = {}
local grandTotalHit = 0
local grandTotalExecutable = 0
Expand Down
Loading
Loading