diff --git a/.luaurc b/.luaurc index f1ac351..5b22a1b 100644 --- a/.luaurc +++ b/.luaurc @@ -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", diff --git a/CHANGELOG.md b/CHANGELOG.md index 63b2153..8c47f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)` diff --git a/Source/Testable/Coverage.luau b/Source/Testable/Coverage.luau index 9075a58..e254d8d 100644 --- a/Source/Testable/Coverage.luau +++ b/Source/Testable/Coverage.luau @@ -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. --]] @@ -20,6 +21,8 @@ local EXCLUDED_SUFFIXES = { "%.client$", "%.plugin$", "%.spec$", + "%.story$", + "%.storybook$", } export type FunctionCoverage = { @@ -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 --[[ @@ -183,64 +196,30 @@ 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), @@ -248,6 +227,18 @@ function Coverage.resolveRoots(roots: { [string]: any }): { { Name: string, Scri }) 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 @@ -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 diff --git a/Tests/CoverageTest.spec.luau b/Tests/CoverageTest.spec.luau index 3de153d..9a7b1b8 100644 --- a/Tests/CoverageTest.spec.luau +++ b/Tests/CoverageTest.spec.luau @@ -15,36 +15,14 @@ local Testable = require("../Source/Testable") -- Require the fixture module so it has coverage data local SampleModule = require("./CoverageFixtures/SampleModule") --- Script reference to the fixtures directory +-- Script references local fixturesDir = script.Parent.CoverageFixtures local sampleScript = fixturesDir.SampleModule return function() describe("Coverage.resolveRoots", function() - it("should resolve a dictionary of script instances", function() - local roots = { - Sample = sampleScript, - } - local resolved = Coverage.resolveRoots(roots) - expect(#resolved).to.equal(1) - expect(resolved[1].Name).to.equal("SampleModule") - end) - - it("should sort keys alphabetically", function() - local roots = { - Zebra = sampleScript, - Alpha = sampleScript, - } - local resolved = Coverage.resolveRoots(roots) - -- Same script deduplicates to 1 entry - expect(#resolved).to.equal(1) - end) - - it("should discover scripts from a directory root", function() - local roots = { - Fixtures = fixturesDir, - } - local resolved = Coverage.resolveRoots(roots) + it("should discover modules from a directory", function() + local resolved = Coverage.resolveRoots(fixturesDir) expect(#resolved >= 1).to.equal(true) local foundSample = false @@ -56,9 +34,18 @@ return function() expect(foundSample).to.equal(true) end) - it("should handle empty dictionary", function() - local resolved = Coverage.resolveRoots({}) - expect(#resolved).to.equal(0) + it("should resolve a single file script", function() + local resolved = Coverage.resolveRoots(sampleScript) + expect(#resolved).to.equal(1) + expect(resolved[1].Name).to.equal("SampleModule") + end) + + it("should exclude .spec files", function() + local testsDir = script.Parent + local resolved = Coverage.resolveRoots(testsDir) + for _, entry in resolved do + expect(entry.Name:match("%.spec$")).to.equal(nil) + end end) end) @@ -66,9 +53,7 @@ return function() it("should return a report with correct structure", function() SampleModule.add(1, 2) - local report = Coverage.collect({ - Fixtures = fixturesDir, - }) + local report = Coverage.collect(fixturesDir) if report == nil then return @@ -84,40 +69,28 @@ return function() SampleModule.add(1, 2) SampleModule.subtract(3, 1) - local report = Coverage.collect({ - Fixtures = fixturesDir, - }) + local report = Coverage.collect(sampleScript) if report == nil then return end - expect(#report.Files >= 1).to.equal(true) - local sampleFile = nil - for _, file in report.Files do - if file.Name == "SampleModule" then - sampleFile = file - end - end - expect(sampleFile).to.be.ok() - expect(sampleFile.ExecutableLines).to.be.a("number") - expect(sampleFile.HitLines).to.be.a("number") - expect(sampleFile.Percentage).to.be.a("number") + expect(#report.Files).to.equal(1) + expect(report.Files[1].Name).to.equal("SampleModule") + expect(report.Files[1].ExecutableLines).to.be.a("number") + expect(report.Files[1].HitLines).to.be.a("number") + expect(report.Files[1].Percentage).to.be.a("number") end) it("should include local functions in coverage", function() - -- Call double() which internally calls local helper() SampleModule.double(5) - local report = Coverage.collect({ - Sample = sampleScript, - }) + local report = Coverage.collect(sampleScript) if report == nil then return end - -- File-level coverage should include the local helper function local file = report.Files[1] local foundHelper = false for _, fn in file.Functions do @@ -132,9 +105,7 @@ return function() it("should return nil when coverage is disabled", function() local hasApi = type(debug.iscoverageenabled) == "function" if hasApi and debug.iscoverageenabled() then - local report = Coverage.collect({ - Sample = sampleScript, - }) + local report = Coverage.collect(sampleScript) expect(report).to.be.ok() end end) @@ -143,9 +114,7 @@ return function() SampleModule.add(1, 2) SampleModule.subtract(3, 1) - local report = Coverage.collect({ - Sample = sampleScript, - }) + local report = Coverage.collect(sampleScript) if report == nil then return @@ -189,9 +158,8 @@ return function() end) it("should set CoverageRoots", function() - local roots = { Foo = script } - Config.set({ CoverageRoots = roots }) - expect(Config.CoverageRoots).to.equal(roots) + Config.set({ CoverageRoots = script }) + expect(Config.CoverageRoots).to.equal(script) end) it("should set CoverageThreshold", function() @@ -220,7 +188,7 @@ return function() Config.set({ Coverage = true }) end Config.set({ CoverageThreshold = 90 }) - Config.set({ CoverageRoots = { Foo = script } }) + Config.set({ CoverageRoots = script }) Config.reset() expect(Config.Coverage).to.equal(false) expect(Config.CoverageRoots).to.equal(nil) @@ -242,7 +210,7 @@ return function() Testable.configure({ Coverage = true, CoverageThreshold = 0, - CoverageRoots = { Sample = sampleScript }, + CoverageRoots = sampleScript, }) local _, passed = Testable.run({ @@ -260,7 +228,7 @@ return function() it("should not run coverage when Coverage is false", function() Testable.configure({ Coverage = false, - CoverageRoots = { Sample = sampleScript }, + CoverageRoots = sampleScript, }) local _, passed = Testable.run({ diff --git a/VERSION b/VERSION index 9084fa2..524cb55 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 +1.1.1 diff --git a/rokit.toml b/rokit.toml index 954d8ba..3f2655e 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,6 +1,6 @@ [tools] luau-lsp = "horsenuggets/luau-lsp@1.63.0-horse.1.5" -lune = "horsenuggets/lune@0.10.4-horse.14.4" +lune = "horsenuggets/lune@0.10.4-horse.14.5" rojo = "horsenuggets/rojo@7.7.0-rc.1-horse.0.6" stylua = "johnnymorganz/stylua@2.3.1" wally = "horsenuggets/wally@0.3.2-horse.5.1" diff --git a/wally.toml b/wally.toml index 52112e6..6965f45 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.0" +version = "1.1.1" license = "MIT" realm = "shared" registry = "https://github.com/UpliftGames/wally-index"