From 58c710a05d5060d7e733197e168c2e1fef29de43 Mon Sep 17 00:00:00 2001 From: HorseNuggets Date: Sat, 10 Jan 2026 17:38:42 -0800 Subject: [PATCH 01/35] Add subprocess tests for fail() function --- Tests/FailTest.spec.luau | 164 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 4 deletions(-) diff --git a/Tests/FailTest.spec.luau b/Tests/FailTest.spec.luau index 91e3490..f2a78fa 100644 --- a/Tests/FailTest.spec.luau +++ b/Tests/FailTest.spec.luau @@ -2,18 +2,174 @@ FailTest -Tests for the fail() function in the Testable framework. +Tests for the fail() function in the Testable framework. Uses subprocess execution to +verify that fail() actually causes tests to fail with the expected exit codes. --]] +local fs = require("@lune/fs") +local process = require("@lune/process") + +local testCounter = 0 + +local function createTempScript(): string + testCounter += 1 + local timestamp = os.time() + local random = math.random(100000, 999999) + local scriptName = `_temp_fail_test_{timestamp}_{testCounter}_{random}.luau` + return scriptName +end + +local function runTestScript(scriptContent: string): (boolean, string) + local scriptName = createTempScript() + local projectRoot = process.cwd + + -- Write the test script to the project root (where lune is available) + fs.writeFile(`{projectRoot}/{scriptName}`, scriptContent) + + -- Run the test script with lune from the project root + local result = process.exec("lune", { "run", scriptName }, { + cwd = projectRoot, + }) + + local output = (result.stdout or "") .. (result.stderr or "") + + -- Clean up the temp script + fs.removeFile(`{projectRoot}/{scriptName}`) + + return result.ok, output +end + return function() describe("fail() function", function() it("should be available in test environment", function() expect(fail).to.be.a("function") end) - -- Note: We can't easily test that fail() actually fails a test from within - -- a test, because calling fail() would fail the current test. The following - -- tests verify fail() exists and is callable. + -- All subprocess tests are combined into a single test to avoid parallel execution issues. + -- Each subprocess creates its own isolated Testable instance. + it("should cause tests to fail correctly (subprocess tests)", function() + -- Test 1: fail without message + do + local scriptContent = [[ + local Testable = require("./Source/Testable") + + local tests = { + { + Name = "FailingTest", + Func = function() + describe("fail test", function() + it("should fail", function() + fail() + end) + end) + end, + }, + } + + local _, passed = Testable.run(tests) + local process = require("@lune/process") + process.exit(if passed then 0 else 1) + ]] + local success, output = runTestScript(scriptContent) + expect(success).to.equal(false) + expect(string.find(output, "fail() was called", 1, true)).to.be.ok() + end + + -- Test 2: fail with custom message + do + local scriptContent = [[ + local Testable = require("./Source/Testable") + + local tests = { + { + Name = "FailingTest", + Func = function() + describe("fail test", function() + it("should fail with message", function() + fail("custom failure message") + end) + end) + end, + }, + } + + local _, passed = Testable.run(tests) + local process = require("@lune/process") + process.exit(if passed then 0 else 1) + ]] + local success, output = runTestScript(scriptContent) + expect(success).to.equal(false) + expect(string.find(output, "custom failure message", 1, true)).to.be.ok() + end + + -- Test 3: fail should not affect other passing tests + do + local scriptContent = [[ + local Testable = require("./Source/Testable") + + local tests = { + { + Name = "MixedTest", + Func = function() + describe("mixed tests", function() + it("should pass first", function() + expect(true).to.equal(true) + end) + + it("should fail", function() + fail("intentional failure") + end) + + it("should pass after failure", function() + expect(true).to.equal(true) + end) + end) + end, + }, + } + + local results, passed = Testable.run(tests) + print("PASS_COUNT:" .. results.successCount) + print("FAIL_COUNT:" .. results.failureCount) + local process = require("@lune/process") + process.exit(if passed then 0 else 1) + ]] + local success, output = runTestScript(scriptContent) + expect(success).to.equal(false) + expect(string.find(output, "PASS_COUNT:2", 1, true)).to.be.ok() + expect(string.find(output, "FAIL_COUNT:1", 1, true)).to.be.ok() + end + + -- Test 4: fail should work when called conditionally + do + local scriptContent = [[ + local Testable = require("./Source/Testable") + + local tests = { + { + Name = "ConditionalFailTest", + Func = function() + describe("conditional fail", function() + it("should fail when condition is met", function() + local shouldFail = true + if shouldFail then + fail("condition was met") + end + end) + end) + end, + }, + } + + local _, passed = Testable.run(tests) + local process = require("@lune/process") + process.exit(if passed then 0 else 1) + ]] + local success, output = runTestScript(scriptContent) + expect(success).to.equal(false) + expect(string.find(output, "condition was met", 1, true)).to.be.ok() + end + end) end) end From b017b5c449d083b47a4d5bc7195f21a11f7c7570 Mon Sep 17 00:00:00 2001 From: HorseNuggets Date: Sat, 10 Jan 2026 17:39:17 -0800 Subject: [PATCH 02/35] Add .spec suffix to test file headers --- Tests/ConfigTest.spec.luau | 2 +- Tests/ExampleTest.spec.luau | 2 +- Tests/ExpectationTest.spec.luau | 2 +- Tests/FailTest.spec.luau | 2 +- Tests/LifecycleTest.spec.luau | 2 +- Tests/VersionUpdateTest.spec.luau | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Tests/ConfigTest.spec.luau b/Tests/ConfigTest.spec.luau index e3a5557..c254dfb 100644 --- a/Tests/ConfigTest.spec.luau +++ b/Tests/ConfigTest.spec.luau @@ -1,6 +1,6 @@ --[[ -ConfigTest +ConfigTest.spec Tests for the Testable configuration system including setting and resetting options. diff --git a/Tests/ExampleTest.spec.luau b/Tests/ExampleTest.spec.luau index b5fb46f..6c3a6f9 100644 --- a/Tests/ExampleTest.spec.luau +++ b/Tests/ExampleTest.spec.luau @@ -1,6 +1,6 @@ --[[ -ExampleTest +ExampleTest.spec A simple example test demonstrating basic Testable usage. diff --git a/Tests/ExpectationTest.spec.luau b/Tests/ExpectationTest.spec.luau index a8830f1..d214454 100644 --- a/Tests/ExpectationTest.spec.luau +++ b/Tests/ExpectationTest.spec.luau @@ -1,6 +1,6 @@ --[[ -ExpectationTest +ExpectationTest.spec Tests for all expectation matchers in the Testable framework, including equality checks, type assertions, nil checks, numeric comparisons, error throwing, and negation. diff --git a/Tests/FailTest.spec.luau b/Tests/FailTest.spec.luau index f2a78fa..581e6c4 100644 --- a/Tests/FailTest.spec.luau +++ b/Tests/FailTest.spec.luau @@ -1,6 +1,6 @@ --[[ -FailTest +FailTest.spec Tests for the fail() function in the Testable framework. Uses subprocess execution to verify that fail() actually causes tests to fail with the expected exit codes. diff --git a/Tests/LifecycleTest.spec.luau b/Tests/LifecycleTest.spec.luau index 8fddc13..5c001c4 100644 --- a/Tests/LifecycleTest.spec.luau +++ b/Tests/LifecycleTest.spec.luau @@ -1,6 +1,6 @@ --[[ -LifecycleTest +LifecycleTest.spec Tests for lifecycle hooks in the Testable framework including beforeEach, afterEach, beforeAll, and afterAll hooks. diff --git a/Tests/VersionUpdateTest.spec.luau b/Tests/VersionUpdateTest.spec.luau index 20814b2..46b9f38 100644 --- a/Tests/VersionUpdateTest.spec.luau +++ b/Tests/VersionUpdateTest.spec.luau @@ -1,6 +1,6 @@ --[[ -VersionUpdateTest +VersionUpdateTest.spec Tests for the EnsureProperVersionUpdate script. Validates semantic versioning enforcement, version bump validation, and edge cases for version format handling. From d542ed04d1df166a5c6afddc42fbe52be8bbea5a Mon Sep 17 00:00:00 2001 From: horsenuggets Date: Mon, 12 Jan 2026 15:23:51 -0700 Subject: [PATCH 03/35] Add claude-md-luau submodule --- .gitmodules | 3 + CLAUDE.md | 113 +------------------------------------- Submodules/claude-md-luau | 1 + 3 files changed, 5 insertions(+), 112 deletions(-) create mode 100644 .gitmodules create mode 160000 Submodules/claude-md-luau diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..bcefdc4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Submodules/claude-md-luau"] + path = Submodules/claude-md-luau + url = git@github.com:horsenuggets/claude-md-luau.git diff --git a/CLAUDE.md b/CLAUDE.md index 50b20f2..5584df7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,114 +1,3 @@ # Claude Code Guidelines -## Commits - -- Always break commits down into logical parts -- Do not co-author yourself in commits - -## Formatting - -- Run `stylua .` often to ensure that code is formatted properly -- Every file should end in a single newline -- Text should be LF normalized -- Prefer Luau string interpolation using backticks, like `` `Here is a string with an interpolated {value}.` `` -- Prefer double quotes over single quotes -- Always read through existing code to match style - -## Luau File Headers - -Every Luau file should have this at the top: - -```luau ---[[ - - - - - ---]] -``` - -For `init.luau` files, use the parent folder name instead of "init". - -## Comments - -- All comments should word-wrap at column 90 - -## Functions - -- Always add runtime typechecking to function parameters using assert - -## Operators - -- Use compound assignment operators (`+=`, `-=`, `*=`, `/=`) instead of expanded form - -## Print Statements - -- Avoid using colons `:` in prints for stylistic reasons -- Structure everything in complete sentences -- Surround strings of interest in quotation marks `"` -- Use `[Usage]` instead of `Usage:` for usage messages - -## Versioning - -- Version tags should NOT have a "v" prefix (use `0.0.1`, not `v0.0.1`) - -## Changelog Format - -CHANGELOG.md should follow this format: - -```md -# Changelog - -## 0.0.2 - -### Added - -- This is an example addition -- This is another example addition - -### Changed - -- This is an example change -- This is another example change - -### Fixed - -- This is an example fix -- This is another example fix - -## 0.0.1 - -### Added - -- This is an example addition -- This is another example addition - -### Changed - -- This is an example change -- This is another example change - -### Fixed - -- This is an example fix -- This is another example fix -``` - -## Ordering - -- When things can be sorted alphabetically, definitely do that (e.g., imports, table keys, function parameters) - -## Tests - -- For TestEZ-style tests, do not wrap everything in a describe block with just the file name -- The file name is already used as the test name, so a wrapping describe block is redundant - -## Lune Documentation - -You can read Lune documentation as needed to understand the Lune code you're writing: - -- https://lune-org.github.io/docs/api-reference/fs -- https://lune-org.github.io/docs/api-reference/net -- https://lune-org.github.io/docs/api-reference/process -- https://lune-org.github.io/docs/api-reference/* +Detailed guidelines for this project can be found at [`Submodules/claude-md-luau/CLAUDE.md`](Submodules/claude-md-luau/CLAUDE.md). diff --git a/Submodules/claude-md-luau b/Submodules/claude-md-luau new file mode 160000 index 0000000..fddbf33 --- /dev/null +++ b/Submodules/claude-md-luau @@ -0,0 +1 @@ +Subproject commit fddbf332450f23dae56add936b8da0fa1d98ccd7 From 679e448211bb2c522651edd7efcc2bf639b12659 Mon Sep 17 00:00:00 2001 From: horsenuggets Date: Mon, 12 Jan 2026 15:23:55 -0700 Subject: [PATCH 04/35] Update copyright year to 2026 --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index f21b91f..66b6234 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 HorseNuggets +Copyright (c) 2026 HorseNuggets Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 99229caff9c05759a8f7285417dbde3c59526527 Mon Sep 17 00:00:00 2001 From: horsenuggets Date: Mon, 12 Jan 2026 15:23:57 -0700 Subject: [PATCH 05/35] Use lowercase project name in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7671492..590c209 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Testable +# testable A Luau testing framework based off of TestEZ. Testable extends TestEZ with parallel test execution and support for [Lune](https://lune-org.github.io/docs), allowing you to run tests outside of the Roblox environment. From 5a7619dcea5c37bd77b28a5aec2e7df3fce3559a Mon Sep 17 00:00:00 2001 From: horsenuggets Date: Mon, 12 Jan 2026 15:46:09 -0700 Subject: [PATCH 06/35] Add VS Code workspace configuration --- testable.code-workspace | 45 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 testable.code-workspace diff --git a/testable.code-workspace b/testable.code-workspace new file mode 100644 index 0000000..8265834 --- /dev/null +++ b/testable.code-workspace @@ -0,0 +1,45 @@ +{ + "extensions": { + "recommendations": ["JohnnyMorganz.luau-lsp", "JohnnyMorganz.stylua"] + }, + "folders": [ + { + "path": "." + } + ], + "settings": { + "[lua]": { + "editor.defaultFormatter": "JohnnyMorganz.stylua", + "editor.formatOnSave": true + }, + "[luau]": { + "editor.defaultFormatter": "JohnnyMorganz.stylua", + "editor.formatOnSave": true + }, + "editor.rulers": [ + { + "color": "#ffffff10", + "column": 90 // For comments + }, + { + "color": "#ffffff10", + "column": 120 // For code + } + ], + "luau-lsp.completion.autocompleteEnd": true, + "luau-lsp.completion.enableFragmentAutocomplete": true, + "luau-lsp.completion.fillCallArguments": false, + "luau-lsp.completion.imports.enabled": true, + "luau-lsp.completion.imports.separateGroupsWithLine": true, + "luau-lsp.completion.imports.stringRequires.enabled": true, + "luau-lsp.fflags.enableNewSolver": true, + "luau-lsp.hover.multilineFunctionDefinitions": true, + "luau-lsp.hover.showTableKinds": true, + "luau-lsp.inlayHints.hideHintsForErrorTypes": true, + "luau-lsp.sourcemap.autogenerate": true, + "luau-lsp.sourcemap.enabled": true, + "luau-lsp.sourcemap.rojoProjectFile": "default.project.json", + "luau-lsp.sourcemap.sourcemapFile": "sourcemap.json", + "search.useIgnoreFiles": false + } +} From 289d9e0babac75d199416b86d756b3fd3580ed2e Mon Sep 17 00:00:00 2001 From: horsenuggets Date: Mon, 12 Jan 2026 17:01:00 -0700 Subject: [PATCH 07/35] Remove header from root init.luau --- init.luau | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/init.luau b/init.luau index aaa8b22..0ccf7e4 100644 --- a/init.luau +++ b/init.luau @@ -1,9 +1 @@ ---[[ - -testable - -Re-exports the Testable module for package consumers. - ---]] - -return require("./testable/Source/Testable") +return require("@self/Source/Testable") From 68ae2485d81aadfe82d0b5a0095d2a4e634df660 Mon Sep 17 00:00:00 2001 From: horsenuggets Date: Mon, 12 Jan 2026 17:20:27 -0700 Subject: [PATCH 08/35] Update claude-md-luau submodule --- Submodules/claude-md-luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Submodules/claude-md-luau b/Submodules/claude-md-luau index fddbf33..bf77882 160000 --- a/Submodules/claude-md-luau +++ b/Submodules/claude-md-luau @@ -1 +1 @@ -Subproject commit fddbf332450f23dae56add936b8da0fa1d98ccd7 +Subproject commit bf77882b9ad3af50f9130a5fed464c52f6036ef2 From b55f317097339f063b319dc9651e370c55389fbd Mon Sep 17 00:00:00 2001 From: horsenuggets Date: Mon, 12 Jan 2026 20:39:24 -0800 Subject: [PATCH 09/35] Use luau-cicd submodule for CI/CD scripts --- .github/workflows/format.yml | 4 +- .github/workflows/publish.yml | 2 + .github/workflows/release-checks.yml | 15 +++- .github/workflows/test.yml | 2 + .gitmodules | 3 + Scripts/BumpVersion.luau | 68 -------------- Scripts/CheckChangelogVersion.luau | 59 ------------ Scripts/CheckFormatting.luau | 25 ------ Scripts/CheckVersionMatch.luau | 34 ------- Scripts/EnsureProperVersionUpdate.luau | 120 ------------------------- Scripts/Helpers/extractChangelog.luau | 40 --------- Scripts/Helpers/parseVersion.luau | 37 -------- Scripts/SyncVersion.luau | 33 ------- Submodules/luau-cicd | 1 + Tests/VersionUpdateTest.spec.luau | 4 +- 15 files changed, 24 insertions(+), 423 deletions(-) delete mode 100644 Scripts/BumpVersion.luau delete mode 100644 Scripts/CheckChangelogVersion.luau delete mode 100644 Scripts/CheckFormatting.luau delete mode 100644 Scripts/CheckVersionMatch.luau delete mode 100644 Scripts/EnsureProperVersionUpdate.luau delete mode 100644 Scripts/Helpers/extractChangelog.luau delete mode 100644 Scripts/Helpers/parseVersion.luau delete mode 100644 Scripts/SyncVersion.luau create mode 160000 Submodules/luau-cicd diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 7db441a..7abce0d 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -13,9 +13,11 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + submodules: true - name: Setup Rokit uses: CompeyDev/setup-rokit@v0.1.2 - name: Check formatting - run: lune run ./Scripts/CheckFormatting.luau + run: lune run ./Submodules/luau-cicd/Scripts/CheckFormatting.luau diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8c548ff..bf4ceab 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,6 +15,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + submodules: true - name: Setup Rokit uses: CompeyDev/setup-rokit@v0.1.2 diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index 78ac33b..76f74b8 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -12,6 +12,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + submodules: true - name: Setup Rokit uses: CompeyDev/setup-rokit@v0.1.2 @@ -28,12 +30,14 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + submodules: true - name: Setup Rokit uses: CompeyDev/setup-rokit@v0.1.2 - name: Check formatting - run: lune run ./Scripts/CheckFormatting.luau + run: lune run ./Submodules/luau-cicd/Scripts/CheckFormatting.luau analyze: name: Static analysis @@ -41,6 +45,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + submodules: true - name: Setup Rokit uses: CompeyDev/setup-rokit@v0.1.2 @@ -59,15 +65,16 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + submodules: true - name: Setup Rokit uses: CompeyDev/setup-rokit@v0.1.2 - name: Check version update - run: lune run ./Scripts/EnsureProperVersionUpdate.luau + run: lune run ./Submodules/luau-cicd/Scripts/EnsureProperVersionUpdate.luau - name: Check version match - run: lune run ./Scripts/CheckVersionMatch.luau + run: lune run ./Submodules/luau-cicd/Scripts/CheckVersionMatch.luau - name: Check changelog entry - run: lune run ./Scripts/CheckChangelogVersion.luau + run: lune run ./Submodules/luau-cicd/Scripts/CheckChangelogVersion.luau diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c9d8cac..378aebc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + submodules: true - name: Setup Rokit uses: CompeyDev/setup-rokit@v0.1.2 diff --git a/.gitmodules b/.gitmodules index bcefdc4..0d4538a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "Submodules/claude-md-luau"] path = Submodules/claude-md-luau url = git@github.com:horsenuggets/claude-md-luau.git +[submodule "Submodules/luau-cicd"] + path = Submodules/luau-cicd + url = git@github.com:horsenuggets/luau-cicd.git diff --git a/Scripts/BumpVersion.luau b/Scripts/BumpVersion.luau deleted file mode 100644 index 941c7ff..0000000 --- a/Scripts/BumpVersion.luau +++ /dev/null @@ -1,68 +0,0 @@ ---[[ - -BumpVersion - -Bumps the version in VERSION file and syncs it to wally.toml. Pass "major", "minor", or -"patch" as an argument to bump the corresponding version component. - -[Usage] lune run Scripts/BumpVersion - ---]] - -local fs = require("@lune/fs") -local process = require("@lune/process") - -local parseVersion = require("./Helpers/parseVersion") - -local function bumpVersion() - local args = process.args - if #args < 1 then - print("[Usage] lune run Scripts/BumpVersion ") - process.exit(1) - end - - local bumpType = string.lower(args[1]) - if bumpType ~= "major" and bumpType ~= "minor" and bumpType ~= "patch" then - print(`Invalid bump type "{bumpType}". Must be "major", "minor", or "patch".`) - process.exit(1) - end - - local currentVersionString = fs.readFile("VERSION") - local major, minor, patch = parseVersion(currentVersionString) - - if not major then - print(`Could not parse current version "{currentVersionString}".`) - process.exit(1) - end - - local previousVersion = `{major}.{minor}.{patch}` - print(`Current version is "{previousVersion}".`) - - if bumpType == "major" then - major += 1 - minor = 0 - patch = 0 - elseif bumpType == "minor" then - minor += 1 - patch = 0 - else - patch += 1 - end - - local newVersion = `{major}.{minor}.{patch}` - print(`Bumping to "{newVersion}".`) - - fs.writeFile("VERSION", newVersion .. "\n") - - print("Syncing version to wally.toml.") - local syncResult = process.exec("lune", { "run", "Scripts/SyncVersion" }) - if not syncResult.ok then - print("Failed to sync version.") - print(syncResult.stderr) - process.exit(1) - end - - print("Done.") -end - -bumpVersion() diff --git a/Scripts/CheckChangelogVersion.luau b/Scripts/CheckChangelogVersion.luau deleted file mode 100644 index f0834b9..0000000 --- a/Scripts/CheckChangelogVersion.luau +++ /dev/null @@ -1,59 +0,0 @@ ---[[ - -CheckChangelogVersion - -Validates that CHANGELOG.md contains an entry for the current VERSION. - ---]] - -local fs = require("@lune/fs") -local process = require("@lune/process") - -local function checkChangelogVersion() - -- Read current version - if not fs.isFile("VERSION") then - print("VERSION file not found.") - process.exit(1) - end - - local version = fs.readFile("VERSION") - version = string.match(version, "^%s*(.-)%s*$") - - print(`Checking for version "{version}" in CHANGELOG.md...`) - - -- Read changelog - if not fs.isFile("CHANGELOG.md") then - print("CHANGELOG.md not found.") - process.exit(1) - end - - local changelog = fs.readFile("CHANGELOG.md") - - -- Check if version header exists - local pattern = "## " .. version:gsub("%.", "%%.") - if not string.find(changelog, pattern) then - print(`CHANGELOG.md does not contain an entry for version "{version}".`) - print("Please add a changelog section before releasing.") - process.exit(1) - end - - -- Check that the version section has content - local sectionPattern = "## " .. version:gsub("%.", "%%.") .. "\n(.-)\n## " - local section = string.match(changelog, sectionPattern) - - if not section then - -- Try matching to end of file - sectionPattern = "## " .. version:gsub("%.", "%%.") .. "\n(.-)$" - section = string.match(changelog, sectionPattern) - end - - if not section or string.match(section, "^%s*$") then - print(`CHANGELOG.md entry for version "{version}" is empty.`) - print("Please add changelog content before releasing.") - process.exit(1) - end - - print(`CHANGELOG.md contains valid entry for version "{version}".`) -end - -checkChangelogVersion() diff --git a/Scripts/CheckFormatting.luau b/Scripts/CheckFormatting.luau deleted file mode 100644 index 6485b29..0000000 --- a/Scripts/CheckFormatting.luau +++ /dev/null @@ -1,25 +0,0 @@ ---[[ - -CheckFormatting - -Verifies that all code is properly formatted using stylua. - ---]] - -local process = require("@lune/process") - -local function checkFormatting() - local result = process.exec("stylua", { "--check", "." }) - - if result.ok then - print("Formatting check passed!") - process.exit(0) - else - print("Formatting check failed.") - print(result.stdout) - print(result.stderr) - process.exit(1) - end -end - -checkFormatting() diff --git a/Scripts/CheckVersionMatch.luau b/Scripts/CheckVersionMatch.luau deleted file mode 100644 index 8b749cf..0000000 --- a/Scripts/CheckVersionMatch.luau +++ /dev/null @@ -1,34 +0,0 @@ ---[[ - -CheckVersionMatch - -Ensures that the version in VERSION file matches the version in wally.toml. Exits with -code 0 if they match, 1 if they do not. - ---]] - -local fs = require("@lune/fs") -local process = require("@lune/process") - -local function checkVersionMatch() - local versionFileContent = fs.readFile("VERSION") - local version = string.match(versionFileContent, "^%s*(.-)%s*$") - - local wallyContent = fs.readFile("wally.toml") - local wallyVersion = string.match(wallyContent, '%[package%][^%[]*version%s*=%s*"(.-)"') - - if not wallyVersion then - print("Could not find version in wally.toml.") - process.exit(1) - end - - if version == wallyVersion then - print(`Versions match "{version}".`) - process.exit(0) - else - print(`Version mismatch. VERSION file has "{version}" but wally.toml has "{wallyVersion}".`) - process.exit(1) - end -end - -checkVersionMatch() diff --git a/Scripts/EnsureProperVersionUpdate.luau b/Scripts/EnsureProperVersionUpdate.luau deleted file mode 100644 index 5648d92..0000000 --- a/Scripts/EnsureProperVersionUpdate.luau +++ /dev/null @@ -1,120 +0,0 @@ ---[[ - -EnsureProperVersionUpdate - -Validates that the VERSION file contains a proper semantic version and that it has been -bumped correctly since the last release tag. Compares against the most recent git tag -(excluding 0.0.0) to determine if a valid version bump has occurred. - ---]] - -local fs = require("@lune/fs") -local process = require("@lune/process") - -local parseVersion = require("./Helpers/parseVersion") - -local function ensureProperVersionUpdate() - assert(fs.isFile("VERSION"), "VERSION file does not exist.") - - -- Read current VERSION file - local currentVersionString = fs.readFile("VERSION") - local currentMajor, currentMinor, currentPatch = parseVersion(currentVersionString) - - -- Validate current version format - if not currentMajor then - print( - `VERSION file is not in MAJOR.MINOR.PATCH format, current content is "{string.match( - currentVersionString, - "^%s*(.-)%s*$" - )}".` - ) - process.exit(1) - end - - print(`Current version is "{currentMajor}.{currentMinor}.{currentPatch}".`) - - -- Get the latest tag (excluding v0.0.0) - local tagResult = process.exec("git", { "tag", "--sort=-version:refname" }) - - if not tagResult.ok then - print("Could not retrieve git tags.") - print("Version format is valid.") - return - end - - -- Find the first tag that isn't 0.0.0 - local latestTag = nil - for tag in string.gmatch(tagResult.stdout, "[^\n]+") do - if tag ~= "0.0.0" then - latestTag = tag - break - end - end - - if not latestTag then - print("No previous release tags found.") - print("Version format is valid.") - return - end - - print(`Latest release tag is "{latestTag}".`) - - -- Get VERSION from the tag - local versionAtTagResult = process.exec("git", { "show", `{latestTag}:VERSION` }) - - if not versionAtTagResult.ok then - print(`Could not read VERSION at tag "{latestTag}".`) - print("Version format is valid.") - return - end - - local previousVersionString = versionAtTagResult.stdout - local previousMajor, previousMinor, previousPatch = parseVersion(previousVersionString) - - if not previousMajor then - print(`VERSION at tag "{latestTag}" was not in proper format, skipping comparison.`) - print("Current version format is valid.") - return - end - - print(`Version at last release was "{previousMajor}.{previousMinor}.{previousPatch}".`) - - -- Check if versions are identical - if currentMajor == previousMajor and currentMinor == previousMinor and currentPatch == previousPatch then - print("Version has not been updated since last release.") - process.exit(1) - end - - -- Validate proper semantic version bump - -- Only one of these should be true: - -- 1. Major bump: major+1, minor=0, patch=0 - -- 2. Minor bump: major same, minor+1, patch=0 - -- 3. Patch bump: major same, minor same, patch+1 - - local isMajorBump = currentMajor == previousMajor + 1 and currentMinor == 0 and currentPatch == 0 - local isMinorBump = currentMajor == previousMajor and currentMinor == previousMinor + 1 and currentPatch == 0 - local isPatchBump = currentMajor == previousMajor - and currentMinor == previousMinor - and currentPatch == previousPatch + 1 - - if isMajorBump then - print("Valid major version bump.") - elseif isMinorBump then - print("Valid minor version bump.") - elseif isPatchBump then - print("Valid patch version bump.") - else - print("Invalid version bump, must follow semantic versioning.") - print("Valid bumps are") - print(` Major: {previousMajor}.{previousMinor}.{previousPatch} -> {previousMajor + 1}.0.0`) - print(` Minor: {previousMajor}.{previousMinor}.{previousPatch} -> {previousMajor}.{previousMinor + 1}.0`) - print( - ` Patch: {previousMajor}.{previousMinor}.{previousPatch} -> {previousMajor}.{previousMinor}.{previousPatch + 1}` - ) - process.exit(1) - end - - print("Version update is valid.") -end - -ensureProperVersionUpdate() diff --git a/Scripts/Helpers/extractChangelog.luau b/Scripts/Helpers/extractChangelog.luau deleted file mode 100644 index 8e9fc7e..0000000 --- a/Scripts/Helpers/extractChangelog.luau +++ /dev/null @@ -1,40 +0,0 @@ ---[[ - -extractChangelog - -Extracts the changelog section for a specific version from CHANGELOG.md. - ---]] - -local fs = require("@lune/fs") - -local function extractChangelog(version: string): string? - assert(type(version) == "string", "version must be a string") - - if not fs.isFile("CHANGELOG.md") then - return nil - end - - local content = fs.readFile("CHANGELOG.md") - - -- Pattern to match version header and content until next version header or end - local pattern = "## " .. version:gsub("%.", "%%.") .. "\n(.-)\n## " - local section = string.match(content, pattern) - - if not section then - -- Try matching to end of file (for last version in changelog) - pattern = "## " .. version:gsub("%.", "%%.") .. "\n(.-)$" - section = string.match(content, pattern) - end - - if not section then - return nil - end - - -- Trim leading and trailing whitespace - section = string.match(section, "^%s*(.-)%s*$") - - return section -end - -return extractChangelog diff --git a/Scripts/Helpers/parseVersion.luau b/Scripts/Helpers/parseVersion.luau deleted file mode 100644 index a53bade..0000000 --- a/Scripts/Helpers/parseVersion.luau +++ /dev/null @@ -1,37 +0,0 @@ ---[[ - -parseVersion - -Parses a semantic version string in MAJOR.MINOR.PATCH format and returns the three -components as numbers. Returns nil for all three values if the string is not valid. -Version numbers must be between 0 and 99999. - ---]] - -local MAX_VERSION_NUMBER = 99999 - -local function parseVersion(inputVersionString: string): (number?, number?, number?) - assert(typeof(inputVersionString) == "string", "Expected a string.") - - -- Trim whitespace - local versionString: string = string.match(inputVersionString, "^%s*(.-)%s*$") or "" - - -- Parse MAJOR.MINOR.PATCH format - local major, minor, patch = string.match(versionString, "^(%d+)%.(%d+)%.(%d+)$") - - if not major or not minor or not patch then - return nil, nil, nil - end - - local majorNum, minorNum, patchNum = tonumber(major), tonumber(minor), tonumber(patch) - - -- Validate version numbers are within reasonable bounds - if majorNum > MAX_VERSION_NUMBER or minorNum > MAX_VERSION_NUMBER or patchNum > MAX_VERSION_NUMBER then - print(`Version number exceeds maximum allowed value of {MAX_VERSION_NUMBER}.`) - return nil, nil, nil - end - - return majorNum, minorNum, patchNum -end - -return parseVersion diff --git a/Scripts/SyncVersion.luau b/Scripts/SyncVersion.luau deleted file mode 100644 index eeac55d..0000000 --- a/Scripts/SyncVersion.luau +++ /dev/null @@ -1,33 +0,0 @@ ---[[ - -SyncVersion - -Synchronizes the version from the VERSION file to wally.toml. - ---]] - -local fs = require("@lune/fs") - -local function syncVersion() - -- Read version from VERSION file - local version = fs.readFile("VERSION") - - -- Trim whitespace from version - version = string.match(version, `^%s*(.-)%s*$`) - - -- Read wally.toml - local wallyContent = fs.readFile("wally.toml") - - -- Find and replace version in [package] section only - -- Pattern matches [package] section, then finds the first version = "..." line - local updatedContent = string.gsub(wallyContent, `(%[package%][^%[]*version%s*=%s*)"(.-)"`, function(prefix, _) - return `{prefix}"{version}"` - end, 1) - - -- Write updated content back to wally.toml - fs.writeFile("wally.toml", updatedContent) - - print(`Updated wally.toml version to "{version}"!`) -end - -syncVersion() diff --git a/Submodules/luau-cicd b/Submodules/luau-cicd new file mode 160000 index 0000000..96b009c --- /dev/null +++ b/Submodules/luau-cicd @@ -0,0 +1 @@ +Subproject commit 96b009c4891e48927d073e97475b73c91a1bfefa diff --git a/Tests/VersionUpdateTest.spec.luau b/Tests/VersionUpdateTest.spec.luau index 46b9f38..e071373 100644 --- a/Tests/VersionUpdateTest.spec.luau +++ b/Tests/VersionUpdateTest.spec.luau @@ -172,9 +172,9 @@ end local function copyScriptsToRepo(repoPath: string) fs.writeDir(`{repoPath}/Scripts`) fs.writeDir(`{repoPath}/Scripts/Helpers`) - local scriptContent = fs.readFile("Scripts/EnsureProperVersionUpdate.luau") + local scriptContent = fs.readFile("Submodules/luau-cicd/Scripts/EnsureProperVersionUpdate.luau") fs.writeFile(`{repoPath}/Scripts/EnsureProperVersionUpdate.luau`, scriptContent) - local helperContent = fs.readFile("Scripts/Helpers/parseVersion.luau") + local helperContent = fs.readFile("Submodules/luau-cicd/Scripts/Helpers/parseVersion.luau") fs.writeFile(`{repoPath}/Scripts/Helpers/parseVersion.luau`, helperContent) end From f1304d0c6e499471d76838c5ef3863eeccca9aa5 Mon Sep 17 00:00:00 2001 From: horsenuggets Date: Mon, 12 Jan 2026 21:02:32 -0800 Subject: [PATCH 10/35] Bump version to 0.1.0 --- CHANGELOG.md | 6 ++++++ VERSION | 2 +- wally.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ce70d..eb6ca6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.0 + +### Changed + +- Use luau-cicd submodule for CI/CD scripts instead of local copies + ## 0.0.5 ### Added diff --git a/VERSION b/VERSION index bbdeab6..6e8bf73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.5 +0.1.0 diff --git a/wally.toml b/wally.toml index 5f26f07..7662503 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 = "0.0.5" +version = "0.1.0" license = "MIT" realm = "shared" registry = "https://github.com/UpliftGames/wally-index" From e829db54553eeac5f5e11493344da008fb04eeee Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:39:54 -0700 Subject: [PATCH 11/35] Refactor CI/CD workflow for new release process (#15) --- .github/workflows/ci.yml | 85 ++++++++++++++++++++++++++++ .github/workflows/format.yml | 23 -------- .github/workflows/release-checks.yml | 77 ++++++++++++++++++++++--- .github/workflows/test.yml | 26 --------- 4 files changed, 153 insertions(+), 58 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/format.yml delete mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7da7c2f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +name: CI + +on: + pull_request: + branches: + - main + +jobs: + branch-naming: + name: Validate branch name + runs-on: ubuntu-latest + steps: + - name: Check branch name format + run: | + BRANCH="${{ github.head_ref }}" + echo "Checking branch name: $BRANCH" + + # Must start with a valid prefix + if [[ ! "$BRANCH" =~ ^(feature|bugfix|hotfix|chore|docs|refactor|test)/ ]]; then + echo "::error::Branch name must start with a valid prefix (feature/, bugfix/, hotfix/, chore/, docs/, refactor/, test/)" + exit 1 + fi + + # After prefix, must be lowercase kebab-case + SUFFIX="${BRANCH#*/}" + if [[ ! "$SUFFIX" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "::error::Branch name after prefix must be lowercase kebab-case (e.g., feature/my-new-feature)" + exit 1 + fi + + echo "Branch name is valid." + + format: + name: Check formatting + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Check formatting + run: lune run ./Submodules/luau-cicd/Scripts/CheckFormatting.luau + + test: + name: Run tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Install dependencies + run: wally install + + - name: Run tests + run: lune run ./Scripts/RunTests.luau + + analyze: + name: Static analysis + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Install dependencies + run: wally install + + - name: Setup Lune typedefs + run: lune setup --no-update-luaurc + + - name: Run static analysis + run: luau-lsp analyze --ignore "Source/Testable/init.luau" --ignore "Submodules/**" --platform standard . diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml deleted file mode 100644 index 7abce0d..0000000 --- a/.github/workflows/format.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Format - -on: - push: - branches: - - main - -jobs: - format: - name: Check formatting - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - submodules: true - - - name: Setup Rokit - uses: CompeyDev/setup-rokit@v0.1.2 - - - name: Check formatting - run: lune run ./Submodules/luau-cicd/Scripts/CheckFormatting.luau diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index 76f74b8..f51eaad 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -6,23 +6,48 @@ on: - release jobs: - test: - name: Run tests + pr-title: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - name: Check PR title format + run: | + TITLE="${{ github.event.pull_request.title }}" + echo "Checking PR title: $TITLE" + + # Must be exactly "Release X.Y.Z" + if [[ ! "$TITLE" =~ ^Release\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::PR title must be exactly 'Release X.Y.Z' (e.g., 'Release 1.2.3')" + exit 1 + fi + + echo "PR title is valid." + + diff-check: + name: Verify diff matches main runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 with: + fetch-depth: 0 submodules: true - - name: Setup Rokit - uses: CompeyDev/setup-rokit@v0.1.2 + - name: Check diff with main + run: | + git fetch origin main - - name: Install dependencies - run: wally install + # Get the diff between the PR branch and main + DIFF=$(git diff origin/main..HEAD) - - name: Run tests - run: lune run ./Scripts/RunTests.luau + if [ -n "$DIFF" ]; then + echo "::error::PR branch has changes that differ from main. The release branch must contain exactly what is in main." + echo "Diff:" + echo "$DIFF" + exit 1 + fi + + echo "PR branch matches main exactly." format: name: Check formatting @@ -39,6 +64,24 @@ jobs: - name: Check formatting run: lune run ./Submodules/luau-cicd/Scripts/CheckFormatting.luau + test: + name: Run tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Install dependencies + run: wally install + + - name: Run tests + run: lune run ./Scripts/RunTests.luau + analyze: name: Static analysis runs-on: ubuntu-latest @@ -51,11 +94,14 @@ jobs: - name: Setup Rokit uses: CompeyDev/setup-rokit@v0.1.2 + - name: Install dependencies + run: wally install + - name: Setup Lune typedefs run: lune setup --no-update-luaurc - name: Run static analysis - run: luau-lsp analyze --ignore "Source/Testable/init.luau" --platform standard . + run: luau-lsp analyze --ignore "Source/Testable/init.luau" --ignore "Submodules/**" --platform standard . version: name: Validate version @@ -78,3 +124,16 @@ jobs: - name: Check changelog entry run: lune run ./Submodules/luau-cicd/Scripts/CheckChangelogVersion.luau + + - name: Verify PR title matches VERSION + run: | + VERSION=$(cat VERSION | tr -d '[:space:]') + EXPECTED_TITLE="Release $VERSION" + ACTUAL_TITLE="${{ github.event.pull_request.title }}" + + if [ "$EXPECTED_TITLE" != "$ACTUAL_TITLE" ]; then + echo "::error::PR title '$ACTUAL_TITLE' does not match VERSION file. Expected '$EXPECTED_TITLE'" + exit 1 + fi + + echo "PR title matches VERSION file." diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 378aebc..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Test - -on: - push: - branches: - - main - -jobs: - test: - name: Run tests - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - submodules: true - - - name: Setup Rokit - uses: CompeyDev/setup-rokit@v0.1.2 - - - name: Install dependencies - run: wally install - - - name: Run tests - run: lune run ./Scripts/RunTests.luau From a744ab9f5cfc129704588b6bc44516d90673be85 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:47:08 -0700 Subject: [PATCH 12/35] Remove unsupported block_newline_gaps from stylua config (#16) --- stylua.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/stylua.toml b/stylua.toml index 84ef959..9084e15 100644 --- a/stylua.toml +++ b/stylua.toml @@ -7,7 +7,6 @@ quote_style = "AutoPreferDouble" call_parentheses = "Always" collapse_simple_statement = "Never" space_after_function_names = "Never" -block_newline_gaps = "Never" [sort_requires] enabled = true From 5635f833a6fe5ee7e37a93c8ea6dcff9dcb81ae7 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Tue, 13 Jan 2026 00:36:25 -0700 Subject: [PATCH 13/35] Remove diff-check from release workflow (#17) ## Summary - Remove the "Verify diff matches main" check from release-checks.yml - This allows PRs to release from pre-release branches, not just main ## Test plan - [ ] Release checks workflow still runs correctly Generated with [Claude Code](https://claude.com/claude-code) --- .github/workflows/release-checks.yml | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index f51eaad..8460156 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -23,32 +23,6 @@ jobs: echo "PR title is valid." - diff-check: - name: Verify diff matches main - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: true - - - name: Check diff with main - run: | - git fetch origin main - - # Get the diff between the PR branch and main - DIFF=$(git diff origin/main..HEAD) - - if [ -n "$DIFF" ]; then - echo "::error::PR branch has changes that differ from main. The release branch must contain exactly what is in main." - echo "Diff:" - echo "$DIFF" - exit 1 - fi - - echo "PR branch matches main exactly." - format: name: Check formatting runs-on: ubuntu-latest From 882f06b50de5c023f7f49f1175361b73d619df22 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:06:43 -0800 Subject: [PATCH 14/35] Add diff-check back to release workflow (#18) ## Summary - Add back the "Verify diff matches main" check to release-checks.yml - Ensures release PRs exactly match main before merging ## Test plan - [ ] Release checks workflow runs correctly Generated with [Claude Code](https://claude.com/claude-code) --- .github/workflows/release-checks.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index 8460156..f51eaad 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -23,6 +23,32 @@ jobs: echo "PR title is valid." + diff-check: + name: Verify diff matches main + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: true + + - name: Check diff with main + run: | + git fetch origin main + + # Get the diff between the PR branch and main + DIFF=$(git diff origin/main..HEAD) + + if [ -n "$DIFF" ]; then + echo "::error::PR branch has changes that differ from main. The release branch must contain exactly what is in main." + echo "Diff:" + echo "$DIFF" + exit 1 + fi + + echo "PR branch matches main exactly." + format: name: Check formatting runs-on: ubuntu-latest From 006fe54c7e5eed8fc6bf61b671a2cf71036c0949 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:04:18 -0800 Subject: [PATCH 15/35] Update CHANGELOG.md to new format (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Remove subsection headers (### Added, ### Changed, ### Fixed) - Remove blank lines between version headers and list items - Follow simplified changelog format from project guidelines ## Test plan - [ ] Verify CHANGELOG.md renders correctly on GitHub - [ ] Confirm format matches other repos 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 --- .github/workflows/ci.yml | 92 ++++++++++++++++++++++++- CHANGELOG.md | 66 ++++++------------ Scripts/RunTests.luau | 2 + Source/Testable/Expectation.luau | 7 +- Source/Testable/ExpectationContext.luau | 1 + Source/Testable/LifecycleHooks.luau | 3 +- Source/Testable/TestPlan.luau | 6 +- Source/Testable/TestPlanner.luau | 17 ++--- Submodules/luau-cicd | 2 +- Tests/ConfigTest.spec.luau | 2 +- Tests/ExampleTest.spec.luau | 2 +- Tests/ExpectationTest.spec.luau | 2 +- Tests/FailTest.spec.luau | 2 +- Tests/LifecycleTest.spec.luau | 2 +- Tests/VersionUpdateTest.spec.luau | 2 +- rokit.toml | 2 +- testable.code-workspace | 5 +- wally.toml | 10 +++ 18 files changed, 155 insertions(+), 70 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7da7c2f..f6df9f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,4 +82,94 @@ jobs: run: lune setup --no-update-luaurc - name: Run static analysis - run: luau-lsp analyze --ignore "Source/Testable/init.luau" --ignore "Submodules/**" --platform standard . + run: lune run ./Submodules/luau-cicd/Scripts/RunStaticAnalysis.luau + + changelog: + name: Check changelog format + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Check changelog format + run: lune run ./Submodules/luau-cicd/Scripts/CheckChangelogFormat.luau + + rokit-format: + name: Check rokit.toml format + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Check rokit.toml format + run: lune run ./Submodules/luau-cicd/Scripts/CheckRokitFormat.luau + + wally-format: + name: Check wally.toml format + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Check wally.toml format + run: lune run ./Submodules/luau-cicd/Scripts/CheckWallyFormat.luau + + file-headers: + name: Check file headers + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Check file headers + run: lune run ./Submodules/luau-cicd/Scripts/CheckFileHeaders.luau + + luaurc-format: + name: Check .luaurc format + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Check .luaurc format + run: lune run ./Submodules/luau-cicd/Scripts/CheckLuaurcFormat.luau + + no-tabs: + name: Check for tabs + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Check for tabs + run: lune run ./Submodules/luau-cicd/Scripts/CheckNoTabs.luau diff --git a/CHANGELOG.md b/CHANGELOG.md index eb6ca6e..1f1ae85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,61 +1,37 @@ # Changelog ## 0.1.0 - -### Changed - -- Use luau-cicd submodule for CI/CD scripts instead of local copies +- Changed to use luau-cicd submodule for CI/CD scripts instead of local copies ## 0.0.5 - -### Added - -- `Testable.configure()` function for runtime configuration of test behavior - - `AlphabeticalSort`: Sort test output alphabetically - - `Indent`: Custom indentation string - - `MaxConcurrency`: Maximum concurrent tests in parallel mode - - `Parallel`: Enable/disable parallel test execution - - `PrintSkipped`: Show skipped tests in output - - `Verbose`: Enable verbose logging -- Static analysis with luau-lsp added to release checks workflow -- All Roblox and TestEZ globals added to `.luaurc` - -### Changed - +- Added `Testable.configure()` function for runtime configuration of test behavior +- Added `AlphabeticalSort` option to sort test output alphabetically +- Added `Indent` option for custom indentation string +- Added `MaxConcurrency` option for maximum concurrent tests in parallel mode +- Added `Parallel` option to enable/disable parallel test execution +- Added `PrintSkipped` option to show skipped tests in output +- Added `Verbose` option to enable verbose logging +- Added static analysis with luau-lsp to release checks workflow +- Added all Roblox and TestEZ globals to `.luaurc` - Improved documentation with better README examples - Standardized Luau file headers ## 0.0.4 - -### Changed - -- Version tags no longer have a "v" prefix (use `0.0.4` instead of `v0.0.4`) +- Changed version tags to no longer have a "v" prefix (use `0.0.4` instead of `v0.0.4`) ## 0.0.3 - -### Fixed - -- Fix wally authentication in publish workflow +- Fixed wally authentication in publish workflow ## 0.0.2 - -### Changed - -- Remove pull_request triggers from test and format workflows -- Compare version against last release tag instead of last commit - -### Fixed - -- Update tests to use git tags for version comparison +- Removed pull_request triggers from test and format workflows +- Changed to compare version against last release tag instead of last commit +- Fixed tests to use git tags for version comparison ## 0.0.1 - -### Added - - Initial release of Testable, a Luau testing framework -- TestEZ-style API with `describe`, `it`, `expect` -- ANSI color support for CLI test output -- Parallel test execution support -- Version validation script for semantic versioning -- CI/CD workflows for testing, formatting, and releases -- Automated wally publishing on release +- Added TestEZ-style API with `describe`, `it`, `expect` +- Added ANSI color support for CLI test output +- Added parallel test execution support +- Added version validation script for semantic versioning +- Added CI/CD workflows for testing, formatting, and releases +- Added automated wally publishing on release diff --git a/Scripts/RunTests.luau b/Scripts/RunTests.luau index 6754b7c..2e0b514 100644 --- a/Scripts/RunTests.luau +++ b/Scripts/RunTests.luau @@ -1,3 +1,5 @@ +#!/usr/bin/env -S lune run + --[[ RunTests diff --git a/Source/Testable/Expectation.luau b/Source/Testable/Expectation.luau index 4f921ea..afd2222 100644 --- a/Source/Testable/Expectation.luau +++ b/Source/Testable/Expectation.luau @@ -1,4 +1,5 @@ --!nocheck + --[[ Expectation @@ -139,9 +140,9 @@ end This makes chains like: - expect(5) - .never.to.equal(6) - .to.equal(5) + expect(5) + .never.to.equal(6) + .to.equal(5) Work as expected. ]] diff --git a/Source/Testable/ExpectationContext.luau b/Source/Testable/ExpectationContext.luau index d839e62..15ede28 100644 --- a/Source/Testable/ExpectationContext.luau +++ b/Source/Testable/ExpectationContext.luau @@ -1,4 +1,5 @@ --!nocheck + --[[ ExpectationContext diff --git a/Source/Testable/LifecycleHooks.luau b/Source/Testable/LifecycleHooks.luau index 6e99808..ab78dfe 100644 --- a/Source/Testable/LifecycleHooks.luau +++ b/Source/Testable/LifecycleHooks.luau @@ -3,7 +3,8 @@ LifecycleHooks Manages lifecycle hooks (beforeAll, afterAll, beforeEach, afterEach) for test execution. -Maintains a stack of hooks that can be pushed and popped as test nodes are entered and exited. +Maintains a stack of hooks that can be pushed and popped as test nodes are entered and +exited. --]] diff --git a/Source/Testable/TestPlan.luau b/Source/Testable/TestPlan.luau index 5bfcf2c..da0b3b2 100644 --- a/Source/Testable/TestPlan.luau +++ b/Source/Testable/TestPlan.luau @@ -93,9 +93,9 @@ local function newEnvironment(currentNode, extraEnvironment) end --[[ - This function is deprecated. Calling it is a no-op beyond generating a - warning. - ]] + This function is deprecated. Calling it is a no-op beyond generating a + warning. + ]] function env.HACK_NO_XPCALL() warn( "HACK_NO_XPCALL is deprecated. It is now safe to yield in an " diff --git a/Source/Testable/TestPlanner.luau b/Source/Testable/TestPlanner.luau index eeff09d..872ad20 100644 --- a/Source/Testable/TestPlanner.luau +++ b/Source/Testable/TestPlanner.luau @@ -17,14 +17,15 @@ local TestPlanner = {} variants), which will be turned into a test plan to be executed. Parameters: - - modulesList - list of tables describing test modules { - method, -- specification function described above - path, -- array of parent entires, first element is the leaf that owns `method` - pathStringForSorting -- a string representation of `path`, used for sorting of the test plan - } - - testNamePattern - Only tests matching this Lua pattern string will run. Pass empty or nil to run all tests - - extraEnvironment - Lua table holding additional functions and variables to be injected into the specification - function during execution + - modulesList - list of tables describing test modules { + method, -- specification function described above + path, -- array of parent entires, first element is the leaf that owns `method` + pathStringForSorting -- a string representation of `path`, used for sorting + } + - testNamePattern - Only tests matching this Lua pattern string will run. Pass + empty or nil to run all tests + - extraEnvironment - Lua table holding additional functions and variables to be + injected into the specification function during execution ]] function TestPlanner.createPlan( modulesList: { any }, diff --git a/Submodules/luau-cicd b/Submodules/luau-cicd index 96b009c..691e291 160000 --- a/Submodules/luau-cicd +++ b/Submodules/luau-cicd @@ -1 +1 @@ -Subproject commit 96b009c4891e48927d073e97475b73c91a1bfefa +Subproject commit 691e29191fa4fdf6b96a9ededaff960ba9dcb404 diff --git a/Tests/ConfigTest.spec.luau b/Tests/ConfigTest.spec.luau index c254dfb..e3a5557 100644 --- a/Tests/ConfigTest.spec.luau +++ b/Tests/ConfigTest.spec.luau @@ -1,6 +1,6 @@ --[[ -ConfigTest.spec +ConfigTest Tests for the Testable configuration system including setting and resetting options. diff --git a/Tests/ExampleTest.spec.luau b/Tests/ExampleTest.spec.luau index 6c3a6f9..b5fb46f 100644 --- a/Tests/ExampleTest.spec.luau +++ b/Tests/ExampleTest.spec.luau @@ -1,6 +1,6 @@ --[[ -ExampleTest.spec +ExampleTest A simple example test demonstrating basic Testable usage. diff --git a/Tests/ExpectationTest.spec.luau b/Tests/ExpectationTest.spec.luau index d214454..a8830f1 100644 --- a/Tests/ExpectationTest.spec.luau +++ b/Tests/ExpectationTest.spec.luau @@ -1,6 +1,6 @@ --[[ -ExpectationTest.spec +ExpectationTest Tests for all expectation matchers in the Testable framework, including equality checks, type assertions, nil checks, numeric comparisons, error throwing, and negation. diff --git a/Tests/FailTest.spec.luau b/Tests/FailTest.spec.luau index 581e6c4..f2a78fa 100644 --- a/Tests/FailTest.spec.luau +++ b/Tests/FailTest.spec.luau @@ -1,6 +1,6 @@ --[[ -FailTest.spec +FailTest Tests for the fail() function in the Testable framework. Uses subprocess execution to verify that fail() actually causes tests to fail with the expected exit codes. diff --git a/Tests/LifecycleTest.spec.luau b/Tests/LifecycleTest.spec.luau index 5c001c4..8fddc13 100644 --- a/Tests/LifecycleTest.spec.luau +++ b/Tests/LifecycleTest.spec.luau @@ -1,6 +1,6 @@ --[[ -LifecycleTest.spec +LifecycleTest Tests for lifecycle hooks in the Testable framework including beforeEach, afterEach, beforeAll, and afterAll hooks. diff --git a/Tests/VersionUpdateTest.spec.luau b/Tests/VersionUpdateTest.spec.luau index e071373..13ff70e 100644 --- a/Tests/VersionUpdateTest.spec.luau +++ b/Tests/VersionUpdateTest.spec.luau @@ -1,6 +1,6 @@ --[[ -VersionUpdateTest.spec +VersionUpdateTest Tests for the EnsureProperVersionUpdate script. Validates semantic versioning enforcement, version bump validation, and edge cases for version format handling. diff --git a/rokit.toml b/rokit.toml index efc5408..6e52a52 100644 --- a/rokit.toml +++ b/rokit.toml @@ -3,4 +3,4 @@ luau-lsp = "johnnymorganz/luau-lsp@1.60.0" lune = "horsenuggets/lune@0.10.5" rojo = "rojo-rbx/rojo@7.6.0" stylua = "johnnymorganz/stylua@2.3.1" -wally = "horsenuggets/wally@0.3.4" +wally = "horsenuggets/wally@0.3.2-horse.1.0" diff --git a/testable.code-workspace b/testable.code-workspace index 8265834..49011bc 100644 --- a/testable.code-workspace +++ b/testable.code-workspace @@ -40,6 +40,9 @@ "luau-lsp.sourcemap.enabled": true, "luau-lsp.sourcemap.rojoProjectFile": "default.project.json", "luau-lsp.sourcemap.sourcemapFile": "sourcemap.json", - "search.useIgnoreFiles": false + "search.exclude": { + "**/Submodules/**": true + }, + "search.useIgnoreFiles": true } } diff --git a/wally.toml b/wally.toml index 7662503..36a0889 100644 --- a/wally.toml +++ b/wally.toml @@ -6,3 +6,13 @@ license = "MIT" realm = "shared" registry = "https://github.com/UpliftGames/wally-index" repository = "https://github.com/horsenuggets/testable" +include = [ + "default.project.json", + "init.luau", + "LICENSE", + "README.md", + "Source", + "Source/**", + "wally.toml", +] +exclude = ["**"] From 148f77f2d3261985fd53234f33af933eb7992f14 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Sun, 18 Jan 2026 18:32:11 -0800 Subject: [PATCH 16/35] Update submodules to latest (#22) ## Summary - Updates claude-md-luau submodule to latest - Updates luau-cicd submodule to latest (includes version-match CI check) ## Test plan - [ ] CI passes --- .editorconfig | 4 ++++ Submodules/claude-md-luau | 2 +- Submodules/luau-cicd | 2 +- dev.project.json | 12 ++++++++++++ rokit.toml | 2 +- 5 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 dev.project.json diff --git a/.editorconfig b/.editorconfig index d9122d4..c68cda4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,3 +13,7 @@ indent_size = 4 [*.{lua,luau}] indent_style = space indent_size = 4 + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/Submodules/claude-md-luau b/Submodules/claude-md-luau index bf77882..7753252 160000 --- a/Submodules/claude-md-luau +++ b/Submodules/claude-md-luau @@ -1 +1 @@ -Subproject commit bf77882b9ad3af50f9130a5fed464c52f6036ef2 +Subproject commit 77532523b7794f85b0e363377d4b598b6d348a83 diff --git a/Submodules/luau-cicd b/Submodules/luau-cicd index 691e291..b4dcc6d 160000 --- a/Submodules/luau-cicd +++ b/Submodules/luau-cicd @@ -1 +1 @@ -Subproject commit 691e29191fa4fdf6b96a9ededaff960ba9dcb404 +Subproject commit b4dcc6d044d4c266d1c08d24133291845ccb174a diff --git a/dev.project.json b/dev.project.json new file mode 100644 index 0000000..31dc80e --- /dev/null +++ b/dev.project.json @@ -0,0 +1,12 @@ +{ + "name": "testable", + "tree": { + "$path": "Source/Testable", + "DevPackages": { + "$path": "DevPackages" + }, + "Packages": { + "$path": "Packages" + } + } +} diff --git a/rokit.toml b/rokit.toml index 6e52a52..c7327d4 100644 --- a/rokit.toml +++ b/rokit.toml @@ -3,4 +3,4 @@ luau-lsp = "johnnymorganz/luau-lsp@1.60.0" lune = "horsenuggets/lune@0.10.5" rojo = "rojo-rbx/rojo@7.6.0" stylua = "johnnymorganz/stylua@2.3.1" -wally = "horsenuggets/wally@0.3.2-horse.1.0" +wally = "horsenuggets/wally@0.3.2-horse.4.1" From af1ed575138ae84dbbc8d485558bc595de078842 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:09:00 -0500 Subject: [PATCH 17/35] Update submodules (#23) Update claude-md, claude-md-luau, and luau-cicd submodules to latest. --- .github/workflows/ci.yml | 24 ------------------------ .gitmodules | 3 +++ CLAUDE.md | 2 +- Submodules/claude-md | 1 + Submodules/claude-md-luau | 2 +- Submodules/luau-cicd | 2 +- rokit.toml | 4 ++-- 7 files changed, 9 insertions(+), 29 deletions(-) create mode 160000 Submodules/claude-md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6df9f1..b4cd2f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,30 +6,6 @@ on: - main jobs: - branch-naming: - name: Validate branch name - runs-on: ubuntu-latest - steps: - - name: Check branch name format - run: | - BRANCH="${{ github.head_ref }}" - echo "Checking branch name: $BRANCH" - - # Must start with a valid prefix - if [[ ! "$BRANCH" =~ ^(feature|bugfix|hotfix|chore|docs|refactor|test)/ ]]; then - echo "::error::Branch name must start with a valid prefix (feature/, bugfix/, hotfix/, chore/, docs/, refactor/, test/)" - exit 1 - fi - - # After prefix, must be lowercase kebab-case - SUFFIX="${BRANCH#*/}" - if [[ ! "$SUFFIX" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then - echo "::error::Branch name after prefix must be lowercase kebab-case (e.g., feature/my-new-feature)" - exit 1 - fi - - echo "Branch name is valid." - format: name: Check formatting runs-on: ubuntu-latest diff --git a/.gitmodules b/.gitmodules index 0d4538a..78c73fd 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "Submodules/luau-cicd"] path = Submodules/luau-cicd url = git@github.com:horsenuggets/luau-cicd.git +[submodule "Submodules/claude-md"] + path = Submodules/claude-md + url = https://github.com/horsenuggets/claude-md.git diff --git a/CLAUDE.md b/CLAUDE.md index 5584df7..e987bdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,3 @@ # Claude Code Guidelines -Detailed guidelines for this project can be found at [`Submodules/claude-md-luau/CLAUDE.md`](Submodules/claude-md-luau/CLAUDE.md). +Make sure to load all relevant `CLAUDE.md` files across the repository, including those in submodules. diff --git a/Submodules/claude-md b/Submodules/claude-md new file mode 160000 index 0000000..9a3e711 --- /dev/null +++ b/Submodules/claude-md @@ -0,0 +1 @@ +Subproject commit 9a3e7119faabed6f2ec0bddf4e2a1c9d5d041467 diff --git a/Submodules/claude-md-luau b/Submodules/claude-md-luau index 7753252..216c101 160000 --- a/Submodules/claude-md-luau +++ b/Submodules/claude-md-luau @@ -1 +1 @@ -Subproject commit 77532523b7794f85b0e363377d4b598b6d348a83 +Subproject commit 216c10182732769a6295e2c8c47db8b8db43c4aa diff --git a/Submodules/luau-cicd b/Submodules/luau-cicd index b4dcc6d..c5d9e79 160000 --- a/Submodules/luau-cicd +++ b/Submodules/luau-cicd @@ -1 +1 @@ -Subproject commit b4dcc6d044d4c266d1c08d24133291845ccb174a +Subproject commit c5d9e797adada4aec4c7b3c1a94407d8eca92ede diff --git a/rokit.toml b/rokit.toml index c7327d4..3bc5143 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,6 +1,6 @@ [tools] -luau-lsp = "johnnymorganz/luau-lsp@1.60.0" +luau-lsp = "horsenuggets/luau-lsp@1.63.0-horse.1.2" lune = "horsenuggets/lune@0.10.5" -rojo = "rojo-rbx/rojo@7.6.0" +rojo = "horsenuggets/rojo@7.7.0-rc.1-horse.0.3" stylua = "johnnymorganz/stylua@2.3.1" wally = "horsenuggets/wally@0.3.2-horse.4.1" From 952844044baca89ff325cf7f3b7e93b380679eb0 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:39:34 -0500 Subject: [PATCH 18/35] Standardize CI workflows (#26) Update CI and release-checks workflows to the latest templates from luau-cicd, and update the luau-cicd submodule to latest. --- .github/workflows/ci.yml | 49 +++++++++++++++++++++++++++- .github/workflows/release-checks.yml | 43 ++++++++++++++++++++++-- .luaurc | 2 +- Scripts/Lint.luau | 41 +++++++++++++++++++++++ Submodules/claude-md | 2 +- Submodules/claude-md-luau | 2 +- Submodules/luau-cicd | 2 +- Tests/VersionUpdateTest.spec.luau | 5 ++- rokit.toml | 8 ++--- 9 files changed, 140 insertions(+), 14 deletions(-) create mode 100755 Scripts/Lint.luau diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4cd2f4..9dfc0d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,30 @@ on: - main jobs: + branch-name: + name: Validate branch name + runs-on: ubuntu-latest + steps: + - name: Check branch name format + run: | + BRANCH="${{ github.head_ref }}" + echo "Checking branch name: $BRANCH" + + # Must start with a valid prefix + if [[ ! "$BRANCH" =~ ^(feature|bugfix|hotfix|chore|docs|refactor|test)/ ]]; then + echo "::error::Branch name must start with a valid prefix (feature/, bugfix/, hotfix/, chore/, docs/, refactor/, test/)" + exit 1 + fi + + # After prefix, must be lowercase kebab-case + SUFFIX="${BRANCH#*/}" + if [[ ! "$SUFFIX" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "::error::Branch name after prefix must be lowercase kebab-case (e.g., feature/my-new-feature)" + exit 1 + fi + + echo "Branch name is valid." + format: name: Check formatting runs-on: ubuntu-latest @@ -58,7 +82,7 @@ jobs: run: lune setup --no-update-luaurc - name: Run static analysis - run: lune run ./Submodules/luau-cicd/Scripts/RunStaticAnalysis.luau + run: lune run ./Submodules/luau-cicd/Scripts/RunStaticAnalysis.luau lune changelog: name: Check changelog format @@ -135,6 +159,29 @@ jobs: - name: Check .luaurc format run: lune run ./Submodules/luau-cicd/Scripts/CheckLuaurcFormat.luau + json-format: + name: Check JSON format + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Prettier + run: npm install -g prettier + + - name: Setup Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Check JSON format + run: lune run ./Submodules/luau-cicd/Scripts/CheckJsonFormat.luau + no-tabs: name: Check for tabs runs-on: ubuntu-latest diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index f51eaad..ce5d604 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -6,6 +6,20 @@ on: - release jobs: + wally-auth: + name: Verify Wally auth + runs-on: ubuntu-latest + steps: + - name: Check WALLY_AUTH exists + env: + WALLY_AUTH: ${{ secrets.WALLY_AUTH }} + run: | + if [ -z "$WALLY_AUTH" ]; then + echo "::error::WALLY_AUTH secret is not configured. Run: gh secret set WALLY_AUTH < ~/.wally/auth.toml" + exit 1 + fi + echo "WALLY_AUTH is configured." + pr-title: name: Validate PR title runs-on: ubuntu-latest @@ -31,7 +45,6 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - submodules: true - name: Check diff with main run: | @@ -101,7 +114,30 @@ jobs: run: lune setup --no-update-luaurc - name: Run static analysis - run: luau-lsp analyze --ignore "Source/Testable/init.luau" --ignore "Submodules/**" --platform standard . + run: lune run ./Submodules/luau-cicd/Scripts/RunStaticAnalysis.luau lune + + json-format: + name: Check JSON format + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Prettier + run: npm install -g prettier + + - name: Setup Rokit + uses: CompeyDev/setup-rokit@v0.1.2 + + - name: Check JSON format + run: lune run ./Submodules/luau-cicd/Scripts/CheckJsonFormat.luau version: name: Validate version @@ -125,6 +161,9 @@ jobs: - name: Check changelog entry run: lune run ./Submodules/luau-cicd/Scripts/CheckChangelogVersion.luau + - name: Check changelog format + run: lune run ./Submodules/luau-cicd/Scripts/CheckChangelogFormat.luau + - name: Verify PR title matches VERSION run: | VERSION=$(cat VERSION | tr -d '[:space:]') diff --git a/.luaurc b/.luaurc index 9f89dcf..1d4224d 100644 --- a/.luaurc +++ b/.luaurc @@ -1,6 +1,6 @@ { "aliases": { - "lune": "~/.lune/.typedefs/0.10.5/" + "lune": "~/.lune/.typedefs/0.10.4-horse.12.0/" }, "globals": [ "afterAll", diff --git a/Scripts/Lint.luau b/Scripts/Lint.luau new file mode 100755 index 0000000..9e5d362 --- /dev/null +++ b/Scripts/Lint.luau @@ -0,0 +1,41 @@ +#!/usr/bin/env -S lune run + +--[[ + +Lint + +Runs luau-lsp analyze to report type errors and deprecation warnings. + +[Usage] ./Scripts/Lint.luau + +--]] + +local process = require("@lune/process") + +local function lint() + local result = process.exec("luau-lsp", { + "analyze", + "--platform=lune", + "--no-flags-enabled", + "--enable-new-solver", + "--ignore=DevPackages/**", + "--ignore=Packages/**", + "--ignore=Submodules/**", + ".", + }) + + if result.stdout ~= "" then + print(result.stdout) + end + if result.stderr ~= "" then + print(result.stderr) + end + + if result.ok then + print("No lint errors found.") + else + process.exit(1) + end +end + +lint() diff --git a/Submodules/claude-md b/Submodules/claude-md index 9a3e711..b4466e3 160000 --- a/Submodules/claude-md +++ b/Submodules/claude-md @@ -1 +1 @@ -Subproject commit 9a3e7119faabed6f2ec0bddf4e2a1c9d5d041467 +Subproject commit b4466e3db82855b1afdadd555ceb4fc991d0db93 diff --git a/Submodules/claude-md-luau b/Submodules/claude-md-luau index 216c101..331665e 160000 --- a/Submodules/claude-md-luau +++ b/Submodules/claude-md-luau @@ -1 +1 @@ -Subproject commit 216c10182732769a6295e2c8c47db8b8db43c4aa +Subproject commit 331665edc3028403a1e4ff998f5fe39540369fb6 diff --git a/Submodules/luau-cicd b/Submodules/luau-cicd index c5d9e79..28259f8 160000 --- a/Submodules/luau-cicd +++ b/Submodules/luau-cicd @@ -1 +1 @@ -Subproject commit c5d9e797adada4aec4c7b3c1a94407d8eca92ede +Subproject commit 28259f8524e9f99d50e15b921f454c24686a9378 diff --git a/Tests/VersionUpdateTest.spec.luau b/Tests/VersionUpdateTest.spec.luau index 13ff70e..54b263f 100644 --- a/Tests/VersionUpdateTest.spec.luau +++ b/Tests/VersionUpdateTest.spec.luau @@ -63,7 +63,7 @@ end local function initGitRepo(repoPath: string) local result = process.exec("git", { "init" }, { cwd = repoPath }) if not result.ok then - error(`Failed to init git repo at {repoPath}: {result.stderr}`) + error(`Failed to init Git repo at {repoPath}: {result.stderr}`) end process.exec("git", { "config", "user.email", "test@test.com" }, { cwd = repoPath }) process.exec("git", { "config", "user.name", "Test User" }, { cwd = repoPath }) @@ -104,7 +104,6 @@ end local function getOrCreateTemplate(): string -- Simple spinlock to ensure only one thread creates the template while templateLock.locked do - local task = require("@lune/task") task.wait() end @@ -224,7 +223,7 @@ return function() cleanupTempDir(tempDir) end) - it("should accept valid version format with no git history", function() + it("should accept valid version format with no Git history", function() local tempDir = setupTestRepo() setWorkingVersion(tempDir, "1.0.0") local success, output = runVersionCheck(tempDir) diff --git a/rokit.toml b/rokit.toml index 3bc5143..48bf4ae 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,6 +1,6 @@ [tools] -luau-lsp = "horsenuggets/luau-lsp@1.63.0-horse.1.2" -lune = "horsenuggets/lune@0.10.5" -rojo = "horsenuggets/rojo@7.7.0-rc.1-horse.0.3" +luau-lsp = "horsenuggets/luau-lsp@1.63.0-horse.1.4" +lune = "horsenuggets/lune@0.10.4-horse.12.0" +rojo = "horsenuggets/rojo@7.7.0-rc.1-horse.0.6" stylua = "johnnymorganz/stylua@2.3.1" -wally = "horsenuggets/wally@0.3.2-horse.4.1" +wally = "horsenuggets/wally@0.3.2-horse.5.1" From 455f10eea611b37864392c6b2701ba35f555ef46 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:13:08 -0500 Subject: [PATCH 19/35] Capitalize Git as a proper noun (#24) ## Summary - Capitalize "Git" with an uppercase G in test descriptions and error messages since it is a proper name. --- Source/Testable/Reporters/TextReporter.luau | 67 +++++++-------------- Source/Testable/TestResults.luau | 2 + 2 files changed, 25 insertions(+), 44 deletions(-) diff --git a/Source/Testable/Reporters/TextReporter.luau b/Source/Testable/Reporters/TextReporter.luau index f9ab9e3..b0c650b 100644 --- a/Source/Testable/Reporters/TextReporter.luau +++ b/Source/Testable/Reporters/TextReporter.luau @@ -11,6 +11,7 @@ local TestService = game and game:GetService("TestService") local Ansi = require("../Ansi") local Config = require("../Config") local TestEnum = require("../TestEnum") +local TestResults = require("../TestResults") local INDIVIDUAL_PRINTS = true @@ -22,11 +23,7 @@ local STATUS_SYMBOLS = if IS_LUNE [TestEnum.TestStatus.Failure] = "-", [TestEnum.TestStatus.Skipped] = "~", } - else { - [TestEnum.TestStatus.Success] = "✅", - [TestEnum.TestStatus.Failure] = "❌", - [TestEnum.TestStatus.Skipped] = "🕐", - } + else TestResults.STATUS_SYMBOLS local UNKNOWN_STATUS_SYMBOL = "?" local TextReporter = {} @@ -101,6 +98,25 @@ local function report(root) return buffer end +local function buildSummaryLine(results) + if IS_LUNE then + local passedPart = Ansi.brightGreen(`{results.successCount} passed`) + local failedPart = Ansi.brightRed(`{results.failureCount} failed`) + if Config.PrintSkipped and results.skippedCount > 0 then + local skippedPart = Ansi.brightYellow(`{results.skippedCount} skipped`) + return passedPart .. ", " .. failedPart .. ", " .. skippedPart .. "." + else + return passedPart .. ", " .. failedPart .. "." + end + else + if Config.PrintSkipped and results.skippedCount > 0 then + return `{results.successCount} passed, {results.failureCount} failed, {results.skippedCount} skipped.` + else + return `{results.successCount} passed, {results.failureCount} failed.` + end + end +end + function TextReporter.report(results) if INDIVIDUAL_PRINTS then -- Print each line separately @@ -111,50 +127,13 @@ function TextReporter.report(results) print(line) end - local summaryLine - if IS_LUNE then - local passedPart = Ansi.brightGreen(`{results.successCount} passed`) - local failedPart = Ansi.brightRed(`{results.failureCount} failed`) - if Config.PrintSkipped and results.skippedCount > 0 then - local skippedPart = Ansi.brightYellow(`{results.skippedCount} skipped`) - summaryLine = passedPart .. ", " .. failedPart .. ", " .. skippedPart .. "." - else - summaryLine = passedPart .. ", " .. failedPart .. "." - end - else - if Config.PrintSkipped and results.skippedCount > 0 then - summaryLine = - `{results.successCount} passed, {results.failureCount} failed, {results.skippedCount} skipped.` - else - summaryLine = `{results.successCount} passed, {results.failureCount} failed.` - end - end - print(summaryLine) + print(buildSummaryLine(results)) else -- Print all as one big message - local summaryLine - if IS_LUNE then - local passedPart = Ansi.brightGreen(`{results.successCount} passed`) - local failedPart = Ansi.brightRed(`{results.failureCount} failed`) - if Config.PrintSkipped and results.skippedCount > 0 then - local skippedPart = Ansi.brightYellow(`{results.skippedCount} skipped`) - summaryLine = passedPart .. ", " .. failedPart .. ", " .. skippedPart .. "." - else - summaryLine = passedPart .. ", " .. failedPart .. "." - end - else - if Config.PrintSkipped and results.skippedCount > 0 then - summaryLine = - `{results.successCount} passed, {results.failureCount} failed, {results.skippedCount} skipped.` - else - summaryLine = `{results.successCount} passed, {results.failureCount} failed.` - end - end - local resultBuffer = { "The tests have completed.", table.concat(report(results), "\n"), - summaryLine, + buildSummaryLine(results), } print(table.concat(resultBuffer, "\n")) diff --git a/Source/Testable/TestResults.luau b/Source/Testable/TestResults.luau index cd30408..193437b 100644 --- a/Source/Testable/TestResults.luau +++ b/Source/Testable/TestResults.luau @@ -119,4 +119,6 @@ function TestResults:visualize(root: any?, inputLevel: number?): string return table.concat(buffer, "\n") end +TestResults.STATUS_SYMBOLS = STATUS_SYMBOLS + return TestResults From 0ebd7221682e4b9f2610bd9e4c1c5bbc16476d09 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:31:25 -0500 Subject: [PATCH 20/35] Bump toolchain versions and sync with luau-package-template (#27) Sync unpushed local commits: bump lune, wally, luau-lsp versions and sync with luau-package-template. --- .luaurc | 2 +- Submodules/claude-md | 2 +- Submodules/luau-cicd | 2 +- rokit.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.luaurc b/.luaurc index 1d4224d..b4488fd 100644 --- a/.luaurc +++ b/.luaurc @@ -1,6 +1,6 @@ { "aliases": { - "lune": "~/.lune/.typedefs/0.10.4-horse.12.0/" + "lune": "~/.lune/.typedefs/0.10.4-horse.13.0/" }, "globals": [ "afterAll", diff --git a/Submodules/claude-md b/Submodules/claude-md index b4466e3..8ded70f 160000 --- a/Submodules/claude-md +++ b/Submodules/claude-md @@ -1 +1 @@ -Subproject commit b4466e3db82855b1afdadd555ceb4fc991d0db93 +Subproject commit 8ded70f5a1c5bb8eb20d82abefc95402cf049e9d diff --git a/Submodules/luau-cicd b/Submodules/luau-cicd index 28259f8..1cce839 160000 --- a/Submodules/luau-cicd +++ b/Submodules/luau-cicd @@ -1 +1 @@ -Subproject commit 28259f8524e9f99d50e15b921f454c24686a9378 +Subproject commit 1cce83965c17f2188c74631f36aa37565ce183e3 diff --git a/rokit.toml b/rokit.toml index 48bf4ae..0e0bc81 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,6 +1,6 @@ [tools] luau-lsp = "horsenuggets/luau-lsp@1.63.0-horse.1.4" -lune = "horsenuggets/lune@0.10.4-horse.12.0" +lune = "horsenuggets/lune@0.10.4-horse.13.0" 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" From f314571031b87c35d6c4a1d2aed92df5cfc72650 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:47:53 -0500 Subject: [PATCH 21/35] Add code coverage support and dictionary syntax for test roots (#28) ## Summary - Add code coverage support via `debug.getcoverage` with `CoverageRoots` config - Add dictionary syntax for test roots (`{ TestName = func }`) - Bump version to 0.1.1 ## Test plan - [x] All 93 tests pass (89 existing + 4 new dict syntax tests) - [x] Coverage integration tested with require results - [x] Coverage returns nil when disabled (LUNE_COVERAGE=0) - [x] Both array and dict test root syntax verified - [x] Static analysis passes --- .github/workflows/ci.yml | 37 +- .github/workflows/release-checks.yml | 33 +- .gitignore | 9 +- .luaurc | 2 +- CHANGELOG.md | 7 + Scripts/RunTests.luau | 1 + Source/Testable/Config.luau | 34 ++ Source/Testable/Coverage.luau | 315 ++++++++++++++++++ .../Testable/Reporters/CoverageReporter.luau | 79 +++++ Source/Testable/TestBootstrap.luau | 49 ++- Source/Testable/init.luau | 36 +- Submodules/claude-md | 2 +- Submodules/claude-md-luau | 2 +- Submodules/luau-cicd | 2 +- Tests/ConfigTest.spec.luau | 2 +- Tests/DictSyntaxTest.spec.luau | 83 +++++ Tests/ExampleTest.spec.luau | 2 +- Tests/ExpectationTest.spec.luau | 2 +- Tests/FailTest.spec.luau | 2 +- Tests/LifecycleTest.spec.luau | 2 +- Tests/VersionUpdateTest.spec.luau | 2 +- VERSION | 2 +- rokit.toml | 4 +- wally.toml | 2 +- 24 files changed, 665 insertions(+), 46 deletions(-) mode change 100644 => 100755 Scripts/RunTests.luau create mode 100644 Source/Testable/Coverage.luau create mode 100644 Source/Testable/Reporters/CoverageReporter.luau create mode 100644 Tests/DictSyntaxTest.spec.luau diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dfc0d5..0d3e90b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: uses: CompeyDev/setup-rokit@v0.1.2 - name: Check formatting - run: lune run ./Submodules/luau-cicd/Scripts/CheckFormatting.luau + run: ./Submodules/luau-cicd/Scripts/CheckFormatting.luau test: name: Run tests @@ -61,7 +61,7 @@ jobs: run: wally install - name: Run tests - run: lune run ./Scripts/RunTests.luau + run: ./Scripts/RunTests.luau analyze: name: Static analysis @@ -82,7 +82,24 @@ jobs: run: lune setup --no-update-luaurc - name: Run static analysis - run: lune run ./Submodules/luau-cicd/Scripts/RunStaticAnalysis.luau lune + run: | + cat > /tmp/coverage.d.luau << 'DEFS' + export type CoverageEntry = { Function: string, LineDefined: number, Depth: number, Hits: {number} } + declare debug: { + info: (...any) -> ...any, + traceback: ((string?, number?) -> string) & ((thread, string?, number?) -> string), + getcoverage: (fn: (...any) -> ...any) -> {CoverageEntry}, + iscoverageenabled: () -> boolean, + } + DEFS + luau-lsp analyze \ + --platform=lune \ + --no-flags-enabled \ + --definitions="/tmp/coverage.d.luau" \ + --ignore="DevPackages/**" \ + --ignore="Packages/**" \ + --ignore="Submodules/**" \ + . changelog: name: Check changelog format @@ -97,7 +114,7 @@ jobs: uses: CompeyDev/setup-rokit@v0.1.2 - name: Check changelog format - run: lune run ./Submodules/luau-cicd/Scripts/CheckChangelogFormat.luau + run: ./Submodules/luau-cicd/Scripts/CheckChangelogFormat.luau rokit-format: name: Check rokit.toml format @@ -112,7 +129,7 @@ jobs: uses: CompeyDev/setup-rokit@v0.1.2 - name: Check rokit.toml format - run: lune run ./Submodules/luau-cicd/Scripts/CheckRokitFormat.luau + run: ./Submodules/luau-cicd/Scripts/CheckRokitFormat.luau wally-format: name: Check wally.toml format @@ -127,7 +144,7 @@ jobs: uses: CompeyDev/setup-rokit@v0.1.2 - name: Check wally.toml format - run: lune run ./Submodules/luau-cicd/Scripts/CheckWallyFormat.luau + run: ./Submodules/luau-cicd/Scripts/CheckWallyFormat.luau file-headers: name: Check file headers @@ -142,7 +159,7 @@ jobs: uses: CompeyDev/setup-rokit@v0.1.2 - name: Check file headers - run: lune run ./Submodules/luau-cicd/Scripts/CheckFileHeaders.luau + run: ./Submodules/luau-cicd/Scripts/CheckFileHeaders.luau luaurc-format: name: Check .luaurc format @@ -157,7 +174,7 @@ jobs: uses: CompeyDev/setup-rokit@v0.1.2 - name: Check .luaurc format - run: lune run ./Submodules/luau-cicd/Scripts/CheckLuaurcFormat.luau + run: ./Submodules/luau-cicd/Scripts/CheckLuaurcFormat.luau json-format: name: Check JSON format @@ -180,7 +197,7 @@ jobs: uses: CompeyDev/setup-rokit@v0.1.2 - name: Check JSON format - run: lune run ./Submodules/luau-cicd/Scripts/CheckJsonFormat.luau + run: ./Submodules/luau-cicd/Scripts/CheckJsonFormat.luau no-tabs: name: Check for tabs @@ -195,4 +212,4 @@ jobs: uses: CompeyDev/setup-rokit@v0.1.2 - name: Check for tabs - run: lune run ./Submodules/luau-cicd/Scripts/CheckNoTabs.luau + run: ./Submodules/luau-cicd/Scripts/CheckNoTabs.luau diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index ce5d604..8c0417e 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -75,7 +75,7 @@ jobs: uses: CompeyDev/setup-rokit@v0.1.2 - name: Check formatting - run: lune run ./Submodules/luau-cicd/Scripts/CheckFormatting.luau + run: ./Submodules/luau-cicd/Scripts/CheckFormatting.luau test: name: Run tests @@ -93,7 +93,7 @@ jobs: run: wally install - name: Run tests - run: lune run ./Scripts/RunTests.luau + run: ./Scripts/RunTests.luau analyze: name: Static analysis @@ -114,7 +114,24 @@ jobs: run: lune setup --no-update-luaurc - name: Run static analysis - run: lune run ./Submodules/luau-cicd/Scripts/RunStaticAnalysis.luau lune + run: | + cat > /tmp/coverage.d.luau << 'DEFS' + export type CoverageEntry = { Function: string, LineDefined: number, Depth: number, Hits: {number} } + declare debug: { + info: (...any) -> ...any, + traceback: ((string?, number?) -> string) & ((thread, string?, number?) -> string), + getcoverage: (fn: (...any) -> ...any) -> {CoverageEntry}, + iscoverageenabled: () -> boolean, + } + DEFS + luau-lsp analyze \ + --platform=lune \ + --no-flags-enabled \ + --definitions="/tmp/coverage.d.luau" \ + --ignore="DevPackages/**" \ + --ignore="Packages/**" \ + --ignore="Submodules/**" \ + . json-format: name: Check JSON format @@ -137,7 +154,7 @@ jobs: uses: CompeyDev/setup-rokit@v0.1.2 - name: Check JSON format - run: lune run ./Submodules/luau-cicd/Scripts/CheckJsonFormat.luau + run: ./Submodules/luau-cicd/Scripts/CheckJsonFormat.luau version: name: Validate version @@ -153,16 +170,16 @@ jobs: uses: CompeyDev/setup-rokit@v0.1.2 - name: Check version update - run: lune run ./Submodules/luau-cicd/Scripts/EnsureProperVersionUpdate.luau + run: ./Submodules/luau-cicd/Scripts/EnsureProperVersionUpdate.luau - name: Check version match - run: lune run ./Submodules/luau-cicd/Scripts/CheckVersionMatch.luau + run: ./Submodules/luau-cicd/Scripts/CheckVersionMatch.luau - name: Check changelog entry - run: lune run ./Submodules/luau-cicd/Scripts/CheckChangelogVersion.luau + run: ./Submodules/luau-cicd/Scripts/CheckChangelogVersion.luau - name: Check changelog format - run: lune run ./Submodules/luau-cicd/Scripts/CheckChangelogFormat.luau + run: ./Submodules/luau-cicd/Scripts/CheckChangelogFormat.luau - name: Verify PR title matches VERSION run: | diff --git a/.gitignore b/.gitignore index 89c8d40..02f1674 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,3 @@ -.DS_Store -.env -.local -.vscode *.bak *.gen.* *.lock @@ -9,7 +5,12 @@ *.rbxlx *.rbxm *.rbxmx +.DS_Store +.env +.local +.vscode Build Packages sourcemap.json +/TODO.md Tools diff --git a/.luaurc b/.luaurc index b4488fd..24b9d66 100644 --- a/.luaurc +++ b/.luaurc @@ -1,6 +1,6 @@ { "aliases": { - "lune": "~/.lune/.typedefs/0.10.4-horse.13.0/" + "lune": "~/.lune/.typedefs/0.10.4-horse.14.2/" }, "globals": [ "afterAll", diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f1ae85..25154d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.1.1 +- Added code coverage support via `debug.getcoverage` (requires lune 0.10.4-horse.14.2+) +- Added `Coverage`, `CoverageRoots`, and `CoverageThreshold` configuration options +- Added `CoverageReporter` for formatted coverage output with ANSI colors +- Added dictionary syntax for test roots (`{ TestName = func }` alongside `{ { Name, Func } }`) +- Added `DictSyntaxTest` test suite + ## 0.1.0 - Changed to use luau-cicd submodule for CI/CD scripts instead of local copies diff --git a/Scripts/RunTests.luau b/Scripts/RunTests.luau old mode 100644 new mode 100755 index 2e0b514..6d8a9c1 --- a/Scripts/RunTests.luau +++ b/Scripts/RunTests.luau @@ -13,6 +13,7 @@ local process = require("@lune/process") local TESTS = { { Name = "ConfigTest", Func = require("../Tests/ConfigTest.spec") }, + { Name = "DictSyntaxTest", Func = require("../Tests/DictSyntaxTest.spec") }, { Name = "ExampleTest", Func = require("../Tests/ExampleTest.spec") }, { Name = "ExpectationTest", Func = require("../Tests/ExpectationTest.spec") }, { Name = "FailTest", Func = require("../Tests/FailTest.spec") }, diff --git a/Source/Testable/Config.luau b/Source/Testable/Config.luau index b602adf..8b7c2a1 100644 --- a/Source/Testable/Config.luau +++ b/Source/Testable/Config.luau @@ -9,6 +9,9 @@ default values. export type ConfigOptions = { AlphabeticalSort: boolean?, + Coverage: boolean?, + CoverageRoots: any?, + CoverageThreshold: number?, Indent: string?, MaxConcurrency: number?, Parallel: boolean?, @@ -18,6 +21,9 @@ export type ConfigOptions = { local Config = { AlphabeticalSort = false, + Coverage = false, + CoverageRoots = nil :: any?, + CoverageThreshold = 0, Indent = " ", MaxConcurrency = 4, Parallel = true, @@ -38,6 +44,31 @@ function Config.set(options: ConfigOptions) Config.AlphabeticalSort = options.AlphabeticalSort end + if options.Coverage ~= nil then + assert(type(options.Coverage) == "boolean", "Coverage must be a boolean") + if options.Coverage and not debug.iscoverageenabled() then + error( + "Cannot enable coverage because LUNE_COVERAGE is disabled. " + .. "Coverage is enabled by default. Set LUNE_COVERAGE=1 or " + .. "remove LUNE_COVERAGE=0 from your environment." + ) + end + Config.Coverage = options.Coverage + end + + if options.CoverageRoots ~= nil then + Config.CoverageRoots = options.CoverageRoots + end + + if options.CoverageThreshold ~= nil then + assert(type(options.CoverageThreshold) == "number", "CoverageThreshold must be a number") + assert( + options.CoverageThreshold >= 0 and options.CoverageThreshold <= 100, + "CoverageThreshold must be between 0 and 100" + ) + Config.CoverageThreshold = options.CoverageThreshold + end + if options.Indent ~= nil then assert(type(options.Indent) == "string", "Indent must be a string") Config.Indent = options.Indent @@ -70,6 +101,9 @@ end ]] function Config.reset() Config.AlphabeticalSort = false + Config.Coverage = false + Config.CoverageRoots = nil + Config.CoverageThreshold = 0 Config.Indent = " " Config.MaxConcurrency = 4 Config.Parallel = true diff --git a/Source/Testable/Coverage.luau b/Source/Testable/Coverage.luau new file mode 100644 index 0000000..736d4e4 --- /dev/null +++ b/Source/Testable/Coverage.luau @@ -0,0 +1,315 @@ +--[[ + +Coverage + +Collects code coverage data from instrumented Luau functions using +debug.getcoverage. Recursively discovers modules from CoverageRoots, +which can be script instances (directories) or require results (module +tables). Coverage is enabled by default in Lune; set LUNE_COVERAGE=0 +to disable. + +--]] + +local IS_ROBLOX = game ~= nil + +local EXCLUDED_SUFFIXES = { + "%.legacy$", + "%.server$", + "%.client$", + "%.plugin$", + "%.spec$", +} + +export type FunctionCoverage = { + Name: string, + ExecutableLines: number, + HitLines: number, + Percentage: number, +} + +export type FileCoverage = { + Name: string, + Functions: { FunctionCoverage }, + ExecutableLines: number, + HitLines: number, + Percentage: number, +} + +export type CoverageReport = { + Files: { FileCoverage }, + TotalExecutableLines: number, + TotalHitLines: number, + TotalPercentage: number, +} + +local Coverage = {} + +--[[ + Checks if a module name should be excluded based on its suffix. + + @param name - The module name to check + @return True if the name matches an excluded suffix +]] +local function isExcluded(name: string): boolean + for _, suffix in EXCLUDED_SUFFIXES do + if name:match(suffix) then + return true + end + end + return false +end + +--[[ + Checks if a value is a script Instance (Roblox or Lune filesystem). + + @param value - The value to check + @return True if the value is a script-like Instance +]] +local function isInstance(value: any): boolean + if IS_ROBLOX then + return typeof(value) == "Instance" + end + return typeof(value) == "userdata" + and pcall(function() + local _ = value.Name + local _ = value.Parent + end) +end + +--[[ + Gets the name of a module without the .luau extension. + + @param instance - The script instance + @return The cleaned module name +]] +local function getModuleName(instance: any): string + local name = instance.Name + return name:gsub("%.luau$", "") +end + +--[[ + Recursively discovers all requirable modules from a script Instance + root. Filters out excluded suffixes and caches by identity to avoid + duplicates. + + @param root - The script instance to search from + @param results - Array to accumulate { Name, Module } entries + @param seen - Set of already-processed instances for deduplication +]] +local function discoverFromInstance( + root: any, + results: { { Name: string, Module: { [string]: any } } }, + seen: { [any]: boolean } +) + if seen[root] then + return + end + seen[root] = true + + local children + if IS_ROBLOX then + children = root:GetDescendants() + else + local ok, result = pcall(function() + return root:GetChildren() + end) + if not ok then + return + end + children = result + + -- Recursively get descendants by walking children + local allDescendants = {} + local function walkChildren(parent: any) + local childOk, childResult = pcall(function() + return parent:GetChildren() + end) + if not childOk then + return + end + for _, child in childResult do + table.insert(allDescendants, child) + walkChildren(child) + end + end + walkChildren(root) + children = allDescendants + end + + for _, child in children do + if seen[child] then + continue + end + seen[child] = true + + local isModule = if IS_ROBLOX then child:IsA("ModuleScript") else child.Name:match("%.luau$") ~= nil + + if not isModule then + continue + end + + local name = getModuleName(child) + if isExcluded(name) then + continue + end + + local ok, result = pcall(require, child) + if ok and type(result) == "table" then + table.insert(results, { + Name = name, + Module = result, + }) + end + end +end + +--[[ + Collects coverage data for a single function. + + @param fn - The function to collect coverage for + @return hitLines - Number of lines that were executed + @return executableLines - Number of executable lines +]] +local function collectFunctionCoverage(fn: (...any) -> ...any): (number, number) + local coverage = debug.getcoverage(fn) + local totalExecutable = 0 + local totalHit = 0 + + for _, entry in coverage do + for _, hitCount in entry.Hits do + if hitCount >= 0 then + totalExecutable += 1 + if hitCount > 0 then + totalHit += 1 + end + end + end + end + + return totalHit, totalExecutable +end + +--[[ + Extracts all functions from a module table, sorted alphabetically. + + @param mod - The module table to extract functions from + @return functions - Array of { name, fn } pairs +]] +local function getFunctionsFromModule(mod: { [string]: any }): { { name: string, fn: (...any) -> ...any } } + local result = {} + + for name, value in mod do + if type(value) == "function" then + table.insert(result, { name = name, fn = value }) + end + end + + table.sort(result, function(a, b) + return a.name < b.name + end) + + return result +end + +--[[ + Resolves CoverageRoots into a flat list of { Name, Module } entries. + CoverageRoots uses dictionary syntax: { Name = root, ... } where each + root is either a script instance (recursively discovered) or a require + result (module table used directly). + + @param roots - Dictionary of { name = root } entries + @return modules - Array of { Name, Module } entries +]] +function Coverage.resolveRoots(roots: { [string]: any }): { { Name: string, Module: { [string]: any } } } + local results = {} + local seen: { [any]: boolean } = {} + + -- Sort keys alphabetically for deterministic order + local keys = {} + for key in roots do + table.insert(keys, key) + end + table.sort(keys) + + for _, name in keys do + local root = roots[name] + + if isInstance(root) then + discoverFromInstance(root, results, seen) + elseif type(root) == "table" then + if not seen[root] then + seen[root] = true + table.insert(results, { + Name = name, + Module = root, + }) + end + end + end + + return results +end + +--[[ + Collects coverage data for all resolved modules. Returns nil when + coverage is not enabled (LUNE_COVERAGE=0). + + @param roots - CoverageRoots value from config + @return report - The coverage report, or nil if coverage is disabled +]] +function Coverage.collect(roots: any): CoverageReport? + if not debug.iscoverageenabled() then + return nil + end + + local modules = Coverage.resolveRoots(roots) + local files: { FileCoverage } = {} + local grandTotalHit = 0 + local grandTotalExecutable = 0 + + for _, entry in modules do + local funcs = getFunctionsFromModule(entry.Module) + local fileFunctions: { FunctionCoverage } = {} + local fileHit = 0 + local fileExecutable = 0 + + for _, funcInfo in funcs do + local hit, executable = collectFunctionCoverage(funcInfo.fn) + fileHit += hit + fileExecutable += executable + + local pct = if executable > 0 then math.floor((hit / executable) * 100) else 0 + + table.insert(fileFunctions, { + Name = funcInfo.name, + ExecutableLines = executable, + HitLines = hit, + Percentage = pct, + }) + end + + grandTotalHit += fileHit + grandTotalExecutable += fileExecutable + + local filePct = if fileExecutable > 0 then math.floor((fileHit / fileExecutable) * 100) else 0 + + table.insert(files, { + Name = entry.Name, + Functions = fileFunctions, + ExecutableLines = fileExecutable, + HitLines = fileHit, + Percentage = filePct, + }) + end + + local totalPct = if grandTotalExecutable > 0 then math.floor((grandTotalHit / grandTotalExecutable) * 100) else 0 + + return { + Files = files, + TotalExecutableLines = grandTotalExecutable, + TotalHitLines = grandTotalHit, + TotalPercentage = totalPct, + } +end + +return Coverage diff --git a/Source/Testable/Reporters/CoverageReporter.luau b/Source/Testable/Reporters/CoverageReporter.luau new file mode 100644 index 0000000..6da22c6 --- /dev/null +++ b/Source/Testable/Reporters/CoverageReporter.luau @@ -0,0 +1,79 @@ +--[[ + +CoverageReporter + +Formats and prints code coverage results to standard output. + +--]] + +local Ansi = require("../Ansi") +local Coverage = require("../Coverage") + +local IS_LUNE = Ansi.isLune() + +local CoverageReporter = {} + +local function formatStatus(pct: number, threshold: number): string + if pct >= threshold then + return if IS_LUNE then Ansi.brightGreen("PASS") else "PASS" + elseif pct >= 50 then + return if IS_LUNE then Ansi.brightYellow("WARN") else "WARN" + else + return if IS_LUNE then Ansi.brightRed("FAIL") else "FAIL" + end +end + +local function formatPercentage(pct: number, threshold: number): string + local text = `{pct}%` + if not IS_LUNE then + return text + end + if pct >= threshold then + return Ansi.brightGreen(text) + elseif pct >= 50 then + return Ansi.brightYellow(text) + else + return Ansi.brightRed(text) + end +end + +--[[ + Reports coverage results to standard output. + + @param report - The coverage report from Coverage.collect + @param threshold - The minimum coverage percentage to pass +]] +function CoverageReporter.report(report: Coverage.CoverageReport, threshold: number) + print("") + print("Code Coverage Report") + print(string.rep("-", 60)) + + local header = string.format("%-30s %6s %6s %6s %s", "File", "Lines", "Hit", "Pct", "Status") + print(header) + print(string.rep("-", 60)) + + for _, file in report.Files do + local statusText = formatStatus(file.Percentage, threshold) + local pctText = formatPercentage(file.Percentage, threshold) + + print( + string.format("%-30s %6d %6d %6s %s", file.Name, file.ExecutableLines, file.HitLines, pctText, statusText) + ) + end + + print(string.rep("-", 60)) + + local totalPctText = formatPercentage(report.TotalPercentage, threshold) + print(string.format("%-30s %6d %6d %6s", "TOTAL", report.TotalExecutableLines, report.TotalHitLines, totalPctText)) + print("") + + if report.TotalPercentage < threshold then + local msg = `Coverage {report.TotalPercentage}% is below threshold of {threshold}%.` + print(if IS_LUNE then Ansi.brightRed(msg) else msg) + else + local msg = `Coverage {report.TotalPercentage}% meets threshold of {threshold}%.` + print(if IS_LUNE then Ansi.brightGreen(msg) else msg) + end +end + +return CoverageReporter diff --git a/Source/Testable/TestBootstrap.luau b/Source/Testable/TestBootstrap.luau index 3e06551..734f600 100644 --- a/Source/Testable/TestBootstrap.luau +++ b/Source/Testable/TestBootstrap.luau @@ -145,19 +145,56 @@ function TestBootstrap:getModules(root: any): { any } end --[[ - Gathers test modules from multiple root locations. + Gathers test modules from multiple root locations. Supports both + array format and dictionary format: - @param roots - Array of root instances to search + Array: { { Name = "Test", Func = fn }, ... } + Dict: { Test = fn, Other = fn, ... } + + @param roots - Array or dictionary of test roots @return Combined array of all module descriptors found ]] function TestBootstrap:getModulesFromMultipleRoots(roots: { any }): { any } local modules = {} - for _, root in ipairs(roots) do - local newModules = self:getModules(root) + -- Detect if roots is a dictionary (string keys) or array (integer keys) + local isDictionary = false + if #roots == 0 then + for key in roots do + if type(key) == "string" then + isDictionary = true + break + end + end + end - for _, newModule in ipairs(newModules) do - table.insert(modules, newModule) + if isDictionary then + -- Dictionary format: { TestName = requireResult, ... } + local entries = {} + for name, func in roots do + assert( + type(name) == "string" and type(func) == "function", + `Expected dictionary entries to be string = function, got {type(name)} = {type(func)}` + ) + table.insert(entries, { Name = name, Func = func }) + end + -- Sort alphabetically for deterministic order + table.sort(entries, function(a, b) + return a.Name < b.Name + end) + for _, entry in entries do + local newModules = self:getModules(entry) + for _, newModule in newModules do + table.insert(modules, newModule) + end + end + else + -- Array format: { { Name = "Test", Func = fn }, Instance, ... } + for _, root in ipairs(roots) do + local newModules = self:getModules(root) + for _, newModule in newModules do + table.insert(modules, newModule) + end end end diff --git a/Source/Testable/init.luau b/Source/Testable/init.luau index 3f85b65..29026a4 100644 --- a/Source/Testable/init.luau +++ b/Source/Testable/init.luau @@ -8,6 +8,8 @@ matchers, lifecycle hooks, and parallel test execution support. --]] local Config = require("@self/Config") +local Coverage = require("@self/Coverage") +local CoverageReporter = require("@self/Reporters/CoverageReporter") local Expectation = require("@self/Expectation") local TestBootstrap = require("@self/TestBootstrap") local TestEnum = require("@self/TestEnum") @@ -21,16 +23,26 @@ local TextReporter = require("@self/Reporters/TextReporter") --[[ Executes tests from the specified test roots, generates a test plan, runs the tests, reports the results using the TextReporter, and returns the results and a boolean - indicating whether all tests passed. + indicating whether all tests passed. If coverage is enabled, collects and reports + coverage data after tests complete. @param testRoots - Array of test module roots to execute @return results - The test results object @return passed - Boolean indicating if all tests passed ]] local function run(testRoots: { any }): (any, boolean) - if not testRoots or #testRoots == 0 then - error("testRoots must be a non-empty array") + if not testRoots then + error("testRoots must be a non-empty table") end + if #testRoots == 0 and next(testRoots) == nil then + error("testRoots must be a non-empty table") + end + + -- Snapshot coverage config before tests run, since tests may call + -- Config.reset() which would wipe the coverage settings + local coverageEnabled = Config.Coverage + local coverageRoots = Config.CoverageRoots + local coverageThreshold = Config.CoverageThreshold local modules = TestBootstrap:getModulesFromMultipleRoots(testRoots) local plan = TestPlanner.createPlan(modules) @@ -38,7 +50,21 @@ local function run(testRoots: { any }): (any, boolean) TextReporter.report(results) - return results, results.failureCount == 0 + local passed = results.failureCount == 0 + + if coverageEnabled and coverageRoots ~= nil then + local coverageReport = Coverage.collect(coverageRoots) + + if coverageReport ~= nil then + CoverageReporter.report(coverageReport, coverageThreshold) + + if coverageReport.TotalPercentage < coverageThreshold then + passed = false + end + end + end + + return results, passed end local Testable = { @@ -47,8 +73,10 @@ local Testable = { run = run, Config = Config, + Coverage = Coverage, Expectation = Expectation, Reporters = { + CoverageReporter = CoverageReporter, TextReporter = TextReporter, }, TestBootstrap = TestBootstrap, diff --git a/Submodules/claude-md b/Submodules/claude-md index 8ded70f..39fd226 160000 --- a/Submodules/claude-md +++ b/Submodules/claude-md @@ -1 +1 @@ -Subproject commit 8ded70f5a1c5bb8eb20d82abefc95402cf049e9d +Subproject commit 39fd2266868d6823714fe45d9960e03fe676c102 diff --git a/Submodules/claude-md-luau b/Submodules/claude-md-luau index 331665e..a49c628 160000 --- a/Submodules/claude-md-luau +++ b/Submodules/claude-md-luau @@ -1 +1 @@ -Subproject commit 331665edc3028403a1e4ff998f5fe39540369fb6 +Subproject commit a49c6283eff62916303f38c64a6628a13fd887e3 diff --git a/Submodules/luau-cicd b/Submodules/luau-cicd index 1cce839..a44b67d 160000 --- a/Submodules/luau-cicd +++ b/Submodules/luau-cicd @@ -1 +1 @@ -Subproject commit 1cce83965c17f2188c74631f36aa37565ce183e3 +Subproject commit a44b67d54726632dc6ba3daa03584c9125793d16 diff --git a/Tests/ConfigTest.spec.luau b/Tests/ConfigTest.spec.luau index e3a5557..c254dfb 100644 --- a/Tests/ConfigTest.spec.luau +++ b/Tests/ConfigTest.spec.luau @@ -1,6 +1,6 @@ --[[ -ConfigTest +ConfigTest.spec Tests for the Testable configuration system including setting and resetting options. diff --git a/Tests/DictSyntaxTest.spec.luau b/Tests/DictSyntaxTest.spec.luau new file mode 100644 index 0000000..229757d --- /dev/null +++ b/Tests/DictSyntaxTest.spec.luau @@ -0,0 +1,83 @@ +--[[ + +DictSyntaxTest.spec + +Tests that Testable.run supports dictionary syntax for test roots +alongside the existing array syntax. + +--]] + +local Testable = require("../Source/Testable") + +return function() + describe("Dictionary syntax for test roots", function() + it("should run tests passed as dictionary entries", function() + local ran = false + local miniTests = { + { + Name = "MiniTest", + Func = function() + describe("Mini", function() + it("runs", function() + ran = true + expect(true).to.be.ok() + end) + end) + end, + }, + } + + local _, passed = Testable.run(miniTests) + expect(passed).to.equal(true) + expect(ran).to.equal(true) + end) + + it("should accept dictionary format { Name = func }", function() + local ran = false + local dictTests = { + InlineTest = function() + describe("Inline", function() + it("runs from dict", function() + ran = true + expect(1 + 1).to.equal(2) + end) + end) + end, + } + + local _, passed = Testable.run(dictTests) + expect(passed).to.equal(true) + expect(ran).to.equal(true) + end) + + it("should sort dictionary entries alphabetically", function() + local order = {} + local dictTests = { + ZTest = function() + describe("ZTest", function() + it("runs", function() + table.insert(order, "Z") + end) + end) + end, + ATest = function() + describe("ATest", function() + it("runs", function() + table.insert(order, "A") + end) + end) + end, + } + + Testable.run(dictTests) + expect(order[1]).to.equal("A") + expect(order[2]).to.equal("Z") + end) + + it("should error on empty table", function() + expect(function() + Testable.run({}) + end).to.throw() + end) + end) +end diff --git a/Tests/ExampleTest.spec.luau b/Tests/ExampleTest.spec.luau index b5fb46f..6c3a6f9 100644 --- a/Tests/ExampleTest.spec.luau +++ b/Tests/ExampleTest.spec.luau @@ -1,6 +1,6 @@ --[[ -ExampleTest +ExampleTest.spec A simple example test demonstrating basic Testable usage. diff --git a/Tests/ExpectationTest.spec.luau b/Tests/ExpectationTest.spec.luau index a8830f1..d214454 100644 --- a/Tests/ExpectationTest.spec.luau +++ b/Tests/ExpectationTest.spec.luau @@ -1,6 +1,6 @@ --[[ -ExpectationTest +ExpectationTest.spec Tests for all expectation matchers in the Testable framework, including equality checks, type assertions, nil checks, numeric comparisons, error throwing, and negation. diff --git a/Tests/FailTest.spec.luau b/Tests/FailTest.spec.luau index f2a78fa..581e6c4 100644 --- a/Tests/FailTest.spec.luau +++ b/Tests/FailTest.spec.luau @@ -1,6 +1,6 @@ --[[ -FailTest +FailTest.spec Tests for the fail() function in the Testable framework. Uses subprocess execution to verify that fail() actually causes tests to fail with the expected exit codes. diff --git a/Tests/LifecycleTest.spec.luau b/Tests/LifecycleTest.spec.luau index 8fddc13..5c001c4 100644 --- a/Tests/LifecycleTest.spec.luau +++ b/Tests/LifecycleTest.spec.luau @@ -1,6 +1,6 @@ --[[ -LifecycleTest +LifecycleTest.spec Tests for lifecycle hooks in the Testable framework including beforeEach, afterEach, beforeAll, and afterAll hooks. diff --git a/Tests/VersionUpdateTest.spec.luau b/Tests/VersionUpdateTest.spec.luau index 54b263f..6944425 100644 --- a/Tests/VersionUpdateTest.spec.luau +++ b/Tests/VersionUpdateTest.spec.luau @@ -1,6 +1,6 @@ --[[ -VersionUpdateTest +VersionUpdateTest.spec Tests for the EnsureProperVersionUpdate script. Validates semantic versioning enforcement, version bump validation, and edge cases for version format handling. diff --git a/VERSION b/VERSION index 6e8bf73..17e51c3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.1.1 diff --git a/rokit.toml b/rokit.toml index 0e0bc81..16e1ca9 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,6 +1,6 @@ [tools] -luau-lsp = "horsenuggets/luau-lsp@1.63.0-horse.1.4" -lune = "horsenuggets/lune@0.10.4-horse.13.0" +luau-lsp = "horsenuggets/luau-lsp@1.63.0-horse.1.5" +lune = "horsenuggets/lune@0.10.4-horse.14.2" 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 36a0889..d05f5ba 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 = "0.1.0" +version = "0.1.1" license = "MIT" realm = "shared" registry = "https://github.com/UpliftGames/wally-index" From 450e1d7be8d8cef310d3f37c4c025e116daa0655 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:32:58 -0500 Subject: [PATCH 22/35] Add Roblox cloud E2E tests and fix Roblox compatibility (#30) ## Summary - Add Roblox cloud E2E tests that run Testable in a real Roblox server via Open Cloud API - Fix TestBootstrap to support dict and array syntax in Roblox - Fix coverage to gracefully degrade in Roblox (no debug.iscoverageenabled) - Bump version to 0.1.2 ## Test plan - [x] 100 tests pass (93 local + 7 Roblox cloud) - [x] All 7 Roblox cloud tests pass: describe/it/expect, dict syntax, array syntax, matchers, coverage nil, coverage error, lifecycle hooks --- CHANGELOG.md | 6 + E2E.project.json | 18 ++ Scripts/PublishE2EPlace.luau | 64 +++++++ Scripts/RunTests.luau | 1 + Source/Testable/Config.luau | 22 ++- Source/Testable/Coverage.luau | 3 +- Source/Testable/TestBootstrap.luau | 27 ++- Tests/RobloxCloud/RemoteTestScript.luau | 170 +++++++++++++++++++ Tests/RobloxCloudTest.spec.luau | 217 ++++++++++++++++++++++++ VERSION | 2 +- wally.toml | 2 +- 11 files changed, 508 insertions(+), 24 deletions(-) create mode 100644 E2E.project.json create mode 100755 Scripts/PublishE2EPlace.luau create mode 100644 Tests/RobloxCloud/RemoteTestScript.luau create mode 100644 Tests/RobloxCloudTest.spec.luau diff --git a/CHANGELOG.md b/CHANGELOG.md index 25154d7..4871b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.2 +- Added Roblox cloud E2E tests via Open Cloud Luau Execution API +- Added PublishE2EPlace script and E2E Rojo project +- Fixed TestBootstrap to support dict and array syntax in Roblox (not just Lune) +- Fixed coverage to gracefully degrade when debug.iscoverageenabled doesn't exist (Roblox VM) + ## 0.1.1 - Added code coverage support via `debug.getcoverage` (requires lune 0.10.4-horse.14.2+) - Added `Coverage`, `CoverageRoots`, and `CoverageThreshold` configuration options diff --git a/E2E.project.json b/E2E.project.json new file mode 100644 index 0000000..1fc015c --- /dev/null +++ b/E2E.project.json @@ -0,0 +1,18 @@ +{ + "name": "testable-e2e", + "tree": { + "$className": "DataModel", + "HttpService": { + "$className": "HttpService", + "$properties": { + "HttpEnabled": true + } + }, + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "Testable": { + "$path": "Source/Testable" + } + } + } +} diff --git a/Scripts/PublishE2EPlace.luau b/Scripts/PublishE2EPlace.luau new file mode 100755 index 0000000..9544ac0 --- /dev/null +++ b/Scripts/PublishE2EPlace.luau @@ -0,0 +1,64 @@ +#!/usr/bin/env -S lune run + +--[[ + +PublishE2EPlace + +Builds and publishes the Testable E2E test place to Roblox. This +place contains the Testable source in ReplicatedStorage so remote +test scripts can require it. + +[Usage] ./Scripts/PublishE2EPlace.luau + +--]] + +local fs = require("@lune/fs") +local net = require("@lune/net") +local process = require("@lune/process") +local serde = require("@lune/serde") + +local UNIVERSE_ID = "9873131552" +local PLACE_ID = "93713502617477" + +local function publishE2EPlace() + local apiKey = process.env.ROBLOX_E2E_API_KEY + assert(apiKey ~= nil and apiKey ~= "", "ROBLOX_E2E_API_KEY not found in environment.") + + if not fs.isDir("Build") then + fs.writeDir("Build") + end + + print("Building E2E place with Rojo...") + local result = process.exec("rojo", { + "build", + "E2E.project.json", + "--output", + "Build/E2ETest.rbxl", + }) + + if not result.ok then + error(`Rojo build failed: {result.stderr}`) + end + + local placeData = fs.readFile("Build/E2ETest.rbxl") + print(`Uploading place ({#placeData} bytes)...`) + + local response = net.request({ + url = `https://apis.roblox.com/universes/v1/{UNIVERSE_ID}/places/{PLACE_ID}/versions?versionType=Published`, + method = "POST", + headers = { + ["Content-Type"] = "application/octet-stream", + ["x-api-key"] = apiKey, + }, + body = placeData, + }) + + if response.ok then + local decoded = serde.decode("json", response.body) + print(`Place published successfully (version {decoded.versionNumber}).`) + else + error(`Place upload failed ({response.statusCode}): {response.body}`) + end +end + +publishE2EPlace() diff --git a/Scripts/RunTests.luau b/Scripts/RunTests.luau index 6d8a9c1..ff4fa6c 100755 --- a/Scripts/RunTests.luau +++ b/Scripts/RunTests.luau @@ -18,6 +18,7 @@ local TESTS = { { Name = "ExpectationTest", Func = require("../Tests/ExpectationTest.spec") }, { Name = "FailTest", Func = require("../Tests/FailTest.spec") }, { Name = "LifecycleTest", Func = require("../Tests/LifecycleTest.spec") }, + { Name = "RobloxCloudTest", Func = require("../Tests/RobloxCloudTest.spec") }, { Name = "VersionUpdateTest", Func = require("../Tests/VersionUpdateTest.spec") }, } diff --git a/Source/Testable/Config.luau b/Source/Testable/Config.luau index 8b7c2a1..39a2a96 100644 --- a/Source/Testable/Config.luau +++ b/Source/Testable/Config.luau @@ -46,12 +46,22 @@ function Config.set(options: ConfigOptions) if options.Coverage ~= nil then assert(type(options.Coverage) == "boolean", "Coverage must be a boolean") - if options.Coverage and not debug.iscoverageenabled() then - error( - "Cannot enable coverage because LUNE_COVERAGE is disabled. " - .. "Coverage is enabled by default. Set LUNE_COVERAGE=1 or " - .. "remove LUNE_COVERAGE=0 from your environment." - ) + if options.Coverage then + local hasApi = type(debug.iscoverageenabled) == "function" + if not hasApi then + error( + "Cannot enable coverage because debug.iscoverageenabled " + .. "is not available. Coverage requires Lune " + .. "0.10.4-horse.14.2 or later." + ) + end + if not debug.iscoverageenabled() then + error( + "Cannot enable coverage because LUNE_COVERAGE is disabled. " + .. "Coverage is enabled by default. Set LUNE_COVERAGE=1 or " + .. "remove LUNE_COVERAGE=0 from your environment." + ) + end end Config.Coverage = options.Coverage end diff --git a/Source/Testable/Coverage.luau b/Source/Testable/Coverage.luau index 736d4e4..60257f7 100644 --- a/Source/Testable/Coverage.luau +++ b/Source/Testable/Coverage.luau @@ -258,7 +258,8 @@ end @return report - The coverage report, or nil if coverage is disabled ]] function Coverage.collect(roots: any): CoverageReport? - if not debug.iscoverageenabled() then + local hasApi = type(debug.iscoverageenabled) == "function" + if not hasApi or not debug.iscoverageenabled() then return nil end diff --git a/Source/Testable/TestBootstrap.luau b/Source/Testable/TestBootstrap.luau index 734f600..8b5afa2 100644 --- a/Source/Testable/TestBootstrap.luau +++ b/Source/Testable/TestBootstrap.luau @@ -119,26 +119,23 @@ end function TestBootstrap:getModules(root: any): { any } local modules = {} - if IS_ROBLOX then - local rootType = typeof(root) - assert(rootType == "Instance", "Expected root to be an Instance.") - - getModulesImplRoblox(root, modules) - - for _, child in ipairs(root:GetDescendants()) do - getModulesImplRoblox(root, modules, child) - end - elseif IS_LUNE then - assert( - typeof(root) == "table" and typeof(root.Name) == "string" and typeof(root.Func) == "function", - "Expected root to be a module descriptor table." - ) - + if typeof(root) == "table" and typeof(root.Name) == "string" and typeof(root.Func) == "function" then + -- Module descriptor table: { Name = "Test", Func = fn } + -- Works in both Roblox and Lune table.insert(modules, { method = root.Func, path = { root.Name }, pathStringForSorting = root.Name:lower(), }) + elseif IS_ROBLOX and typeof(root) == "Instance" then + -- Roblox Instance: recursively find .spec ModuleScripts + getModulesImplRoblox(root, modules) + + for _, child in ipairs(root:GetDescendants()) do + getModulesImplRoblox(root, modules, child) + end + else + error(`Unsupported test root type: {typeof(root)}`) end return modules diff --git a/Tests/RobloxCloud/RemoteTestScript.luau b/Tests/RobloxCloud/RemoteTestScript.luau new file mode 100644 index 0000000..14257be --- /dev/null +++ b/Tests/RobloxCloud/RemoteTestScript.luau @@ -0,0 +1,170 @@ +--!nocheck + +--[[ + +RemoteTestScript + +Test script that runs inside a real Roblox place via the Open Cloud +Luau Execution API. Exercises Testable's core features in a Roblox +server environment: describe/it/expect, dictionary syntax, array +syntax, matchers, lifecycle hooks, and coverage graceful degradation. + +--]] + +local ok, Testable = pcall(require, game.ReplicatedStorage.Testable) +if not ok then + print("[FAIL] Module load - " .. tostring(Testable)) + return +end + +local function test(name, fn) + local success, err = pcall(fn) + if success then + print("[PASS] " .. name) + else + print("[FAIL] " .. name .. " - " .. tostring(err)) + end +end + +-- Test 1: Basic describe/it/expect +test("Basic describe/it/expect", function() + local _, passed = Testable.run({ + BasicTest = function() + describe("Basic math", function() + it("should add numbers", function() + expect(1 + 1).to.equal(2) + end) + + it("should subtract numbers", function() + expect(5 - 3).to.equal(2) + end) + end) + end, + }) + assert(passed, "Basic tests should pass") +end) + +-- Test 2: Dictionary syntax for test roots +test("Dictionary syntax", function() + local ran = false + local _, passed = Testable.run({ + DictTest = function() + describe("Dict syntax", function() + it("runs from dict", function() + ran = true + expect(true).to.be.ok() + end) + end) + end, + }) + assert(passed, "Dict syntax tests should pass") + assert(ran, "Test should have actually run") +end) + +-- Test 3: Array syntax (backwards compat) +test("Array syntax (backwards compat)", function() + Testable.resetConfig() + local _, passed = Testable.run({ + { + Name = "ArrayTest", + Func = function() + describe("Array syntax", function() + it("works", function() + expect("hello").to.be.a("string") + end) + end) + end, + }, + }) + assert(passed, "Array syntax tests should pass") +end) + +-- Test 4: Expect matchers +test("Expect matchers", function() + Testable.resetConfig() + local _, passed = Testable.run({ + MatcherTest = function() + describe("Matchers", function() + it("equal", function() + expect(42).to.equal(42) + end) + + it("ok", function() + expect("value").to.be.ok() + end) + + it("type check", function() + expect(true).to.be.a("boolean") + expect(123).to.be.a("number") + expect("hi").to.be.a("string") + end) + + it("never", function() + expect(1).never.to.equal(2) + expect(nil).never.to.be.ok() + end) + + it("throw", function() + expect(function() + error("boom") + end).to.throw() + end) + + it("near", function() + expect(3.14159).to.be.near(3.14159, 0.001) + end) + end) + end, + }) + assert(passed, "Matcher tests should pass") +end) + +-- Test 5: Coverage gracefully returns nil in Roblox +test("Coverage returns nil in Roblox", function() + local Coverage = Testable.Coverage + local report = Coverage.collect({ game.ReplicatedStorage }) + assert(report == nil, "Coverage.collect should return nil in Roblox") +end) + +-- Test 6: Coverage config errors gracefully in Roblox +test("Coverage config errors in Roblox", function() + Testable.resetConfig() + local configOk, configErr = pcall(function() + Testable.configure({ Coverage = true }) + end) + assert(not configOk, "Coverage = true should error in Roblox") + assert(string.find(tostring(configErr), "iscoverageenabled"), "Error should mention iscoverageenabled") +end) + +-- Test 7: Lifecycle hooks +test("Lifecycle hooks", function() + Testable.resetConfig() + local hookOrder = {} + local _, passed = Testable.run({ + LifecycleTest = function() + describe("Lifecycle", function() + beforeAll(function() + table.insert(hookOrder, "beforeAll") + end) + + beforeEach(function() + table.insert(hookOrder, "beforeEach") + end) + + afterEach(function() + table.insert(hookOrder, "afterEach") + end) + + it("first test", function() + table.insert(hookOrder, "test1") + end) + + it("second test", function() + table.insert(hookOrder, "test2") + end) + end) + end, + }) + assert(passed, "Lifecycle tests should pass") + assert(#hookOrder >= 5, "Should have at least 5 hook events, got " .. #hookOrder) +end) diff --git a/Tests/RobloxCloudTest.spec.luau b/Tests/RobloxCloudTest.spec.luau new file mode 100644 index 0000000..bb24d46 --- /dev/null +++ b/Tests/RobloxCloudTest.spec.luau @@ -0,0 +1,217 @@ +--[[ + +RobloxCloudTest.spec + +Runs Testable's test suite in a real Roblox cloud environment using +the Open Cloud Luau Execution API. Submits a test script to a +published place and maps each remote result to a Testable it() block. + +Requires ROBLOX_E2E_API_KEY in the environment. Skips gracefully when +the key is not available. + +The place must have Testable published in ReplicatedStorage. Use +Scripts/PublishE2EPlace.luau to build and publish it. + +--]] + +local fs = require("@lune/fs") +local net = require("@lune/net") +local process = require("@lune/process") +local serde = require("@lune/serde") +local task = require("@lune/task") + +local UNIVERSE_ID = "9873131552" +local PLACE_ID = "93713502617477" +local BASE_URL = "https://apis.roblox.com/cloud/v2" +local POLL_INTERVAL = 3 +local MAX_POLL_ATTEMPTS = 60 +local REMOTE_SCRIPT_PATH = "Tests/RobloxCloud/RemoteTestScript.luau" + +type TestResult = { + Name: string, + Passed: boolean, + Error: string?, +} + +local function _loadApiKey(): string? + local key = process.env.ROBLOX_E2E_API_KEY + if key == nil or key == "" then + return nil + end + return key +end + +local function _createTask(apiKey: string, scriptSource: string): string + local response = net.request({ + url = `{BASE_URL}/universes/{UNIVERSE_ID}/places/{PLACE_ID}/luau-execution-session-tasks`, + method = "POST", + headers = { + ["Content-Type"] = "application/json", + ["x-api-key"] = apiKey, + }, + body = serde.encode("json", { + script = scriptSource, + timeout = "120s", + }), + }) + + if not response.ok then + error(`Task creation failed ({response.statusCode}): {response.body}`) + end + + local decoded = serde.decode("json", response.body) + return decoded.path +end + +local function _pollTask(apiKey: string, taskPath: string): { [string]: any } + for i = 1, MAX_POLL_ATTEMPTS do + task.wait(POLL_INTERVAL) + + local response = net.request({ + url = `{BASE_URL}/{taskPath}`, + method = "GET", + headers = { + ["x-api-key"] = apiKey, + }, + }) + + if not response.ok then + warn(`Poll request failed ({response.statusCode}): {response.body}`) + continue + end + + local decoded = serde.decode("json", response.body) + local state = decoded.state + + if state == "COMPLETE" or state == "FAILED" then + return decoded + end + + if i % 5 == 0 then + print(` Polling... attempt {i}/{MAX_POLL_ATTEMPTS}, state: {state}`) + end + end + + error("Task timed out after polling limit.") +end + +local function _getTaskLogs(apiKey: string, taskPath: string): { string } + local response = net.request({ + url = `{BASE_URL}/{taskPath}/logs`, + method = "GET", + headers = { + ["x-api-key"] = apiKey, + }, + }) + + if not response.ok then + warn(`Failed to get task logs ({response.statusCode}): {response.body}`) + return {} + end + + local decoded = serde.decode("json", response.body) + local logEntries = decoded.luauExecutionSessionTaskLogs + local allMessages: { string } = {} + + if logEntries then + for _, entry in logEntries do + if entry.messages then + for _, message in entry.messages do + table.insert(allMessages, message) + end + end + end + end + + return allMessages +end + +local function _parseTestResults(logs: { string }): { TestResult } + local results: { TestResult } = {} + + for _, line in logs do + local passName = line:match("^%[PASS%] (.+)$") + if passName then + table.insert(results, { + Name = passName, + Passed = true, + }) + continue + end + + local failName, failErr = line:match("^%[FAIL%] (.+) %- (.+)$") + if failName then + table.insert(results, { + Name = failName, + Passed = false, + Error = failErr, + }) + end + end + + return results +end + +local function _runRemoteTests(apiKey: string): { TestResult } + print(" Submitting Luau execution task...") + local testScript = fs.readFile(REMOTE_SCRIPT_PATH) + local taskPath = _createTask(apiKey, testScript) + + print(" Polling for task completion...") + local taskResult = _pollTask(apiKey, taskPath) + + local logs = _getTaskLogs(apiKey, taskPath) + + for _, line in logs do + print(` {line}`) + end + + local results = _parseTestResults(logs) + + if taskResult.state == "FAILED" and #results == 0 then + local errorMsg = "unknown error" + if taskResult.error then + errorMsg = `{taskResult.error.code}: {taskResult.error.message}` + end + table.insert(results, { + Name = "Module load", + Passed = false, + Error = errorMsg, + }) + end + + return results +end + +return function() + local apiKey = _loadApiKey() + + if apiKey == nil then + print(" ROBLOX_E2E_API_KEY not found, Roblox cloud tests will be skipped.") + describe("Roblox Cloud", function() + it("skipped (no API key)", function() end) + end) + return + end + + local results = _runRemoteTests(apiKey) + + if #results == 0 then + describe("Roblox Cloud", function() + it("should have returned test results", function() + error("No test results were parsed from remote execution logs") + end) + end) + return + end + + describe("Roblox Cloud", function() + for _, result in results do + it(result.Name, function() + if not result.Passed then + error(result.Error or "unknown error") + end + end) + end + end) +end diff --git a/VERSION b/VERSION index 17e51c3..d917d3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.1 +0.1.2 diff --git a/wally.toml b/wally.toml index d05f5ba..18fb699 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 = "0.1.1" +version = "0.1.2" license = "MIT" realm = "shared" registry = "https://github.com/UpliftGames/wally-index" From 41cc3623038ad79380bc646f7279c3c5486e5e8d Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:24:27 -0500 Subject: [PATCH 23/35] Run Roblox cloud E2E tests in CI (#32) ## Summary - Publish E2E place before running tests in CI - Pass ROBLOX_E2E_API_KEY secret to test step - Roblox cloud tests now run in CI instead of being skipped ## Test plan - [x] Secret added to repo - [x] CI workflow publishes E2E place then runs all tests including remote --- .github/workflows/ci.yml | 8 ++++++++ .github/workflows/release-checks.yml | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d3e90b..3dfa963 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,8 +60,16 @@ jobs: - name: Install dependencies run: wally install + - name: Publish E2E place + if: env.ROBLOX_E2E_API_KEY != '' + run: ./Scripts/PublishE2EPlace.luau + env: + ROBLOX_E2E_API_KEY: ${{ secrets.ROBLOX_E2E_API_KEY }} + - name: Run tests run: ./Scripts/RunTests.luau + env: + ROBLOX_E2E_API_KEY: ${{ secrets.ROBLOX_E2E_API_KEY }} analyze: name: Static analysis diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index 8c0417e..b8a2853 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -92,8 +92,16 @@ jobs: - name: Install dependencies run: wally install + - name: Publish E2E place + if: env.ROBLOX_E2E_API_KEY != '' + run: ./Scripts/PublishE2EPlace.luau + env: + ROBLOX_E2E_API_KEY: ${{ secrets.ROBLOX_E2E_API_KEY }} + - name: Run tests run: ./Scripts/RunTests.luau + env: + ROBLOX_E2E_API_KEY: ${{ secrets.ROBLOX_E2E_API_KEY }} analyze: name: Static analysis From a7af71ad65222cb5f7cf6f79212683e5e13eb0c5 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:42:59 -0500 Subject: [PATCH 24/35] Add coverage unit tests and bump to 1.0.0 (#33) ## Summary - Add 14 coverage unit tests (resolveRoots, collect, Config options, Testable.run integration) - Bump version to 1.0.0 ## Test plan - [x] 114 tests pass (100 previous + 14 new coverage tests) - [x] Static analysis passes --- CHANGELOG.md | 10 ++ Scripts/RunTests.luau | 1 + Tests/CoverageTest.spec.luau | 278 +++++++++++++++++++++++++++++++++++ VERSION | 2 +- wally.toml | 2 +- 5 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 Tests/CoverageTest.spec.luau diff --git a/CHANGELOG.md b/CHANGELOG.md index 4871b9f..431fed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.0.0 +- Added code coverage support via `debug.getcoverage` with `CoverageRoots` dictionary config +- Added `Coverage`, `CoverageRoots`, and `CoverageThreshold` configuration options +- Added `CoverageReporter` for formatted coverage output with ANSI colors and threshold checking +- Added dictionary syntax for test roots (`{ TestName = func }` alongside `{ { Name, Func } }`) +- Added Roblox cloud E2E tests via Open Cloud Luau Execution API (7 remote tests) +- Added coverage unit tests (14 tests for resolveRoots, collect, Config, and integration) +- Fixed TestBootstrap to support dict and array syntax in Roblox (not just Lune) +- Fixed coverage to gracefully degrade when `debug.iscoverageenabled` doesn't exist (Roblox VM) + ## 0.1.2 - Added Roblox cloud E2E tests via Open Cloud Luau Execution API - Added PublishE2EPlace script and E2E Rojo project diff --git a/Scripts/RunTests.luau b/Scripts/RunTests.luau index ff4fa6c..51333ef 100755 --- a/Scripts/RunTests.luau +++ b/Scripts/RunTests.luau @@ -13,6 +13,7 @@ local process = require("@lune/process") local TESTS = { { Name = "ConfigTest", Func = require("../Tests/ConfigTest.spec") }, + { Name = "CoverageTest", Func = require("../Tests/CoverageTest.spec") }, { Name = "DictSyntaxTest", Func = require("../Tests/DictSyntaxTest.spec") }, { Name = "ExampleTest", Func = require("../Tests/ExampleTest.spec") }, { Name = "ExpectationTest", Func = require("../Tests/ExpectationTest.spec") }, diff --git a/Tests/CoverageTest.spec.luau b/Tests/CoverageTest.spec.luau new file mode 100644 index 0000000..1f2d460 --- /dev/null +++ b/Tests/CoverageTest.spec.luau @@ -0,0 +1,278 @@ +--[[ + +CoverageTest.spec + +Tests for the Coverage module: resolveRoots, collect, and integration +with Config. Coverage data depends on debug.iscoverageenabled being +true, which is the default in Lune 0.10.4-horse.14.2+. + +--]] + +local Config = require("../Source/Testable/Config") +local Coverage = require("../Source/Testable/Coverage") +local Testable = require("../Source/Testable") + +-- Simple modules to measure coverage against +local SampleModule = { + add = function(a: number, b: number): number + return a + b + end, + subtract = function(a: number, b: number): number + return a - b + end, + unused = function() + return "never called" + end, +} + +return function() + describe("Coverage.resolveRoots", function() + it("should resolve a dictionary of module tables", function() + local roots = { + Sample = SampleModule, + } + local resolved = Coverage.resolveRoots(roots) + expect(#resolved).to.equal(1) + expect(resolved[1].Name).to.equal("Sample") + expect(resolved[1].Module).to.equal(SampleModule) + end) + + it("should sort keys alphabetically", function() + local moduleA = { fn = function() end } + local moduleB = { fn = function() end } + local roots = { + Zebra = moduleA, + Alpha = moduleB, + } + local resolved = Coverage.resolveRoots(roots) + expect(resolved[1].Name).to.equal("Alpha") + expect(resolved[2].Name).to.equal("Zebra") + end) + + it("should deduplicate the same module table", function() + local roots = { + First = SampleModule, + Second = SampleModule, + } + local resolved = Coverage.resolveRoots(roots) + expect(#resolved).to.equal(1) + end) + + it("should handle empty dictionary", function() + local resolved = Coverage.resolveRoots({}) + expect(#resolved).to.equal(0) + end) + end) + + describe("Coverage.collect", function() + it("should return a report with correct structure", function() + SampleModule.add(1, 2) + + local report = Coverage.collect({ + Sample = SampleModule, + }) + + if report == nil then + -- Coverage disabled, skip + return + end + + expect(report.Files).to.be.ok() + expect(report.TotalExecutableLines).to.be.a("number") + expect(report.TotalHitLines).to.be.a("number") + expect(report.TotalPercentage).to.be.a("number") + end) + + it("should report per-file coverage", function() + SampleModule.add(1, 2) + SampleModule.subtract(3, 1) + + local report = Coverage.collect({ + Sample = SampleModule, + }) + + if report == nil then + return + end + + expect(#report.Files).to.equal(1) + expect(report.Files[1].Name).to.equal("Sample") + 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 report per-function coverage", function() + SampleModule.add(1, 2) + + local report = Coverage.collect({ + Sample = SampleModule, + }) + + if report == nil then + return + end + + local file = report.Files[1] + expect(#file.Functions > 0).to.equal(true) + + -- Functions should be sorted alphabetically + local names = {} + for _, fn in file.Functions do + table.insert(names, fn.Name) + end + for i = 1, #names - 1 do + expect(names[i] < names[i + 1]).to.equal(true) + end + end) + + it("should return nil when coverage is disabled", function() + -- This test only works when LUNE_COVERAGE=0 + -- When coverage is enabled (default), it returns a report + local hasApi = type(debug.iscoverageenabled) == "function" + if hasApi and debug.iscoverageenabled() then + -- Coverage is enabled, just verify it returns non-nil + local report = Coverage.collect({ Sample = SampleModule }) + expect(report).to.be.ok() + end + -- When disabled, Coverage.collect returns nil (tested in + -- RobloxCloudTest) + end) + + it("should calculate percentage correctly", function() + SampleModule.add(1, 2) + SampleModule.subtract(3, 1) + + local report = Coverage.collect({ + Sample = SampleModule, + }) + + if report == nil then + return + end + + expect(report.TotalPercentage >= 0).to.equal(true) + expect(report.TotalPercentage <= 100).to.equal(true) + + if report.TotalExecutableLines > 0 then + local expected = math.floor((report.TotalHitLines / report.TotalExecutableLines) * 100) + expect(report.TotalPercentage).to.equal(expected) + end + end) + end) + + describe("Config coverage options", function() + afterEach(function() + Config.reset() + end) + + it("should default Coverage to false", function() + expect(Config.Coverage).to.equal(false) + end) + + it("should default CoverageRoots to nil", function() + expect(Config.CoverageRoots).to.equal(nil) + end) + + it("should default CoverageThreshold to 0", function() + expect(Config.CoverageThreshold).to.equal(0) + end) + + it("should set Coverage to true when coverage is available", function() + local hasApi = type(debug.iscoverageenabled) == "function" + if not hasApi then + return + end + + Config.set({ Coverage = true }) + expect(Config.Coverage).to.equal(true) + end) + + it("should set CoverageRoots", function() + local roots = { Foo = {} } + Config.set({ CoverageRoots = roots }) + expect(Config.CoverageRoots).to.equal(roots) + end) + + it("should set CoverageThreshold", function() + Config.set({ CoverageThreshold = 80 }) + expect(Config.CoverageThreshold).to.equal(80) + end) + + it("should error if CoverageThreshold is out of range", function() + expect(function() + Config.set({ CoverageThreshold = -1 }) + end).to.throw() + expect(function() + Config.set({ CoverageThreshold = 101 }) + end).to.throw() + end) + + it("should error if Coverage is not a boolean", function() + expect(function() + Config.set({ Coverage = "yes" :: any }) + end).to.throw() + end) + + it("should reset coverage options", function() + local hasApi = type(debug.iscoverageenabled) == "function" + if hasApi then + Config.set({ Coverage = true }) + end + Config.set({ CoverageThreshold = 90 }) + Config.set({ CoverageRoots = { Foo = {} } }) + Config.reset() + expect(Config.Coverage).to.equal(false) + expect(Config.CoverageRoots).to.equal(nil) + expect(Config.CoverageThreshold).to.equal(0) + end) + end) + + describe("Testable.run with coverage", function() + afterEach(function() + Testable.resetConfig() + end) + + it("should pass when coverage meets threshold", function() + local hasApi = type(debug.iscoverageenabled) == "function" + if not hasApi or not debug.iscoverageenabled() then + return + end + + Testable.configure({ + Coverage = true, + CoverageThreshold = 0, + CoverageRoots = { Sample = SampleModule }, + }) + + local _, passed = Testable.run({ + MiniTest = function() + describe("Mini", function() + it("calls sample", function() + SampleModule.add(1, 2) + end) + end) + end, + }) + expect(passed).to.equal(true) + end) + + it("should not run coverage when Coverage is false", function() + Testable.configure({ + Coverage = false, + CoverageRoots = { Sample = SampleModule }, + }) + + local _, passed = Testable.run({ + MiniTest = function() + describe("Mini", function() + it("passes", function() + expect(true).to.be.ok() + end) + end) + end, + }) + expect(passed).to.equal(true) + end) + end) +end diff --git a/VERSION b/VERSION index d917d3e..3eefcb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.2 +1.0.0 diff --git a/wally.toml b/wally.toml index 18fb699..3e245b3 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 = "0.1.2" +version = "1.0.0" license = "MIT" realm = "shared" registry = "https://github.com/UpliftGames/wally-index" From 1ea9e219e6d41bc3d56485182ab383e0fa7287f5 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:11:34 -0500 Subject: [PATCH 25/35] Update submodules (#35) Update luau-cicd submodule with improved lint output labels and PascalCase config keys. --- Submodules/claude-md | 2 +- Submodules/claude-md-luau | 2 +- Submodules/luau-cicd | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Submodules/claude-md b/Submodules/claude-md index 39fd226..236583a 160000 --- a/Submodules/claude-md +++ b/Submodules/claude-md @@ -1 +1 @@ -Subproject commit 39fd2266868d6823714fe45d9960e03fe676c102 +Subproject commit 236583ae6da206b2025cf00c37e68040996121bf diff --git a/Submodules/claude-md-luau b/Submodules/claude-md-luau index a49c628..787ceb3 160000 --- a/Submodules/claude-md-luau +++ b/Submodules/claude-md-luau @@ -1 +1 @@ -Subproject commit a49c6283eff62916303f38c64a6628a13fd887e3 +Subproject commit 787ceb39ed5fac2c1ddb82148ceba08b6e13c022 diff --git a/Submodules/luau-cicd b/Submodules/luau-cicd index a44b67d..c5054f5 160000 --- a/Submodules/luau-cicd +++ b/Submodules/luau-cicd @@ -1 +1 @@ -Subproject commit a44b67d54726632dc6ba3daa03584c9125793d16 +Subproject commit c5054f58cafa349a5dcfad3d736e5575aced78f5 From dda2dcb64962667e5ad054878b2fccd796ea0068 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:56:10 -0500 Subject: [PATCH 26/35] Use SSH URLs for submodules (#36) Replace HTTPS URLs with SSH in .gitmodules for consistency. --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 78c73fd..1ed4d48 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,4 +6,4 @@ url = git@github.com:horsenuggets/luau-cicd.git [submodule "Submodules/claude-md"] path = Submodules/claude-md - url = https://github.com/horsenuggets/claude-md.git + url = git@github.com:horsenuggets/claude-md.git From c3ea6d97133cc3cc2b235cd1559cf8b721d7a07f Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:18:32 -0500 Subject: [PATCH 27/35] Fix infinite loop on modules with __call metatables (#37) ## Summary - Fix infinite loop when iterating user-provided module tables that have `__call` metamethods - Use `pairs()` instead of generalized `for k, v in table` iteration in Coverage and TestBootstrap ## Test plan - [x] 120 tests pass - [x] Verified fix with chalk-luau (which uses callable tables extensively) --- CHANGELOG.md | 3 +++ Source/Testable/Coverage.luau | 4 ++-- Source/Testable/TestBootstrap.luau | 4 ++-- VERSION | 2 +- wally.toml | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 431fed7..b8a71ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 1.0.1 +- Fixed infinite loop when collecting coverage on modules with `__call` metatables (e.g., chalk-luau) + ## 1.0.0 - Added code coverage support via `debug.getcoverage` with `CoverageRoots` dictionary config - Added `Coverage`, `CoverageRoots`, and `CoverageThreshold` configuration options diff --git a/Source/Testable/Coverage.luau b/Source/Testable/Coverage.luau index 60257f7..1db54c2 100644 --- a/Source/Testable/Coverage.luau +++ b/Source/Testable/Coverage.luau @@ -198,7 +198,7 @@ end local function getFunctionsFromModule(mod: { [string]: any }): { { name: string, fn: (...any) -> ...any } } local result = {} - for name, value in mod do + for name, value in pairs(mod) do if type(value) == "function" then table.insert(result, { name = name, fn = value }) end @@ -226,7 +226,7 @@ function Coverage.resolveRoots(roots: { [string]: any }): { { Name: string, Modu -- Sort keys alphabetically for deterministic order local keys = {} - for key in roots do + for key in pairs(roots) do table.insert(keys, key) end table.sort(keys) diff --git a/Source/Testable/TestBootstrap.luau b/Source/Testable/TestBootstrap.luau index 8b5afa2..83af055 100644 --- a/Source/Testable/TestBootstrap.luau +++ b/Source/Testable/TestBootstrap.luau @@ -157,7 +157,7 @@ function TestBootstrap:getModulesFromMultipleRoots(roots: { any }): { any } -- Detect if roots is a dictionary (string keys) or array (integer keys) local isDictionary = false if #roots == 0 then - for key in roots do + for key in pairs(roots) do if type(key) == "string" then isDictionary = true break @@ -168,7 +168,7 @@ function TestBootstrap:getModulesFromMultipleRoots(roots: { any }): { any } if isDictionary then -- Dictionary format: { TestName = requireResult, ... } local entries = {} - for name, func in roots do + for name, func in pairs(roots) do assert( type(name) == "string" and type(func) == "function", `Expected dictionary entries to be string = function, got {type(name)} = {type(func)}` diff --git a/VERSION b/VERSION index 3eefcb9..7dea76e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0 +1.0.1 diff --git a/wally.toml b/wally.toml index 3e245b3..88b9f7f 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.0.0" +version = "1.0.1" license = "MIT" realm = "shared" registry = "https://github.com/UpliftGames/wally-index" From ba6d6f772030f906e737419de4e052d9d7bea698 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:56:37 -0500 Subject: [PATCH 28/35] Fix lint errors for dual-platform Roblox/Lune support (#39) ## Summary - Add `game` and `tick` to .luaurc globals (needed for runtime environment detection) - Cast `typeof()` to string to avoid Instance type narrowing errors on Lune platform - Ignore `Tests/RobloxCloud/**` from Lune lint pass (Roblox-only test scripts) - Delegate lint script to luau-cicd shared helper - Standardize gitignore format ## Test plan - Verified lint passes locally with no errors or warnings --- .gitignore | 29 ++++++++++++++++++++------- .luaurc | 23 +-------------------- Scripts/Lint.luau | 32 ++++-------------------------- Source/Testable/Coverage.luau | 2 +- Source/Testable/TestBootstrap.luau | 2 +- 5 files changed, 29 insertions(+), 59 deletions(-) diff --git a/.gitignore b/.gitignore index 02f1674..662c15b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,31 @@ -*.bak -*.gen.* -*.lock +# Build artifacts *.rbxl *.rbxlx *.rbxm *.rbxmx +/Build + +# Dependencies +/DevPackages +/Packages +/ServerPackages + +# Editor and environment +.claude .DS_Store .env .local +.mcp.json .vscode -Build -Packages -sourcemap.json + +# Generated files +*.bak +*.gen.* +*.lock +/*sourcemap.json + +# Legacy +aftman.toml + +# Project /TODO.md -Tools diff --git a/.luaurc b/.luaurc index 24b9d66..2496a51 100644 --- a/.luaurc +++ b/.luaurc @@ -7,37 +7,16 @@ "afterEach", "beforeAll", "beforeEach", - "DebuggerManager", - "delay", "describe", "elapsedTime", - "Enum", "expect", "fail", "fdescribe", "fit", "game", - "getfenv", "it", - "plugin", - "PluginManager", - "printidentity", - "script", - "settings", - "shared", - "spawn", - "stats", - "task", "tick", - "time", - "typeof", - "UserSettings", - "version", - "wait", - "warn", - "workspace", "xdescribe", - "xit", - "ypcall" + "xit" ] } diff --git a/Scripts/Lint.luau b/Scripts/Lint.luau index 9e5d362..b5b4257 100755 --- a/Scripts/Lint.luau +++ b/Scripts/Lint.luau @@ -10,32 +10,8 @@ Runs luau-lsp analyze to report type errors and deprecation warnings. --]] -local process = require("@lune/process") +local Lint = require("../Submodules/luau-cicd/Scripts/Helpers/Lint") -local function lint() - local result = process.exec("luau-lsp", { - "analyze", - "--platform=lune", - "--no-flags-enabled", - "--enable-new-solver", - "--ignore=DevPackages/**", - "--ignore=Packages/**", - "--ignore=Submodules/**", - ".", - }) - - if result.stdout ~= "" then - print(result.stdout) - end - if result.stderr ~= "" then - print(result.stderr) - end - - if result.ok then - print("No lint errors found.") - else - process.exit(1) - end -end - -lint() +Lint.run({ + IgnorePatterns = { "Tests/RobloxCloud/**" }, +}) diff --git a/Source/Testable/Coverage.luau b/Source/Testable/Coverage.luau index 1db54c2..8a1142c 100644 --- a/Source/Testable/Coverage.luau +++ b/Source/Testable/Coverage.luau @@ -67,7 +67,7 @@ end ]] local function isInstance(value: any): boolean if IS_ROBLOX then - return typeof(value) == "Instance" + return (typeof(value) :: string) == "Instance" end return typeof(value) == "userdata" and pcall(function() diff --git a/Source/Testable/TestBootstrap.luau b/Source/Testable/TestBootstrap.luau index 83af055..552aea4 100644 --- a/Source/Testable/TestBootstrap.luau +++ b/Source/Testable/TestBootstrap.luau @@ -127,7 +127,7 @@ function TestBootstrap:getModules(root: any): { any } path = { root.Name }, pathStringForSorting = root.Name:lower(), }) - elseif IS_ROBLOX and typeof(root) == "Instance" then + elseif IS_ROBLOX and (typeof(root) :: string) == "Instance" then -- Roblox Instance: recursively find .spec ModuleScripts getModulesImplRoblox(root, modules) From 966cecff7b6dfa0b1882ddfab31b4cd8fa14e730 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:06:06 -0500 Subject: [PATCH 29/35] Update submodules (#40) Update luau-cicd submodule (adds ServerPackages to default lint ignore patterns) --- Submodules/luau-cicd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Submodules/luau-cicd b/Submodules/luau-cicd index c5054f5..100eb30 160000 --- a/Submodules/luau-cicd +++ b/Submodules/luau-cicd @@ -1 +1 @@ -Subproject commit c5054f58cafa349a5dcfad3d736e5575aced78f5 +Subproject commit 100eb3057ab509daa47f50383da6bce66735cad3 From 01055835c9f89869143548b275d645f3e5c8224b Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:06:14 -0500 Subject: [PATCH 30/35] Add file-level coverage via script instances (#41) ## Summary - CoverageRoots now accepts script instances instead of module tables - Coverage data includes all functions in each file, including locals - Supports both individual file scripts and directory discovery - Bumped lune to 0.10.4-horse.14.4 and rojo to 7.7.0-rc.1-horse.0.7 ## Test plan - [x] All 20 coverage tests pass in isolation - [x] Verified on chalk-luau: 50% coverage (was 0% with old approach) --- .luaurc | 2 +- Source/Testable/Context.luau | 6 +- Source/Testable/Coverage.luau | 301 +++++++++++------------ Tests/CoverageFixtures/SampleModule.luau | 33 +++ Tests/CoverageTest.spec.luau | 112 ++++----- Tests/LifecycleTest.spec.luau | 69 ++---- rokit.toml | 2 +- 7 files changed, 251 insertions(+), 274 deletions(-) create mode 100644 Tests/CoverageFixtures/SampleModule.luau diff --git a/.luaurc b/.luaurc index 2496a51..f1ac351 100644 --- a/.luaurc +++ b/.luaurc @@ -1,6 +1,6 @@ { "aliases": { - "lune": "~/.lune/.typedefs/0.10.4-horse.14.2/" + "lune": "~/.lune/.typedefs/0.10.4-horse.14.4/" }, "globals": [ "afterAll", diff --git a/Source/Testable/Context.luau b/Source/Testable/Context.luau index d09d05d..498b6a4 100644 --- a/Source/Testable/Context.luau +++ b/Source/Testable/Context.luau @@ -2,8 +2,9 @@ Context -The Context object implements a write-once key-value store. It also allows for a new -Context object to inherit the entries from an existing one. +The Context object implements a key-value store for passing data between lifecycle hooks +and tests. It also allows for a new Context object to inherit the entries from an existing +one. --]] local Context = {} @@ -26,7 +27,6 @@ function Context.new(parent: any?): any end function meta.__newindex(_obj, key, value) - assert(index[key] == nil, string.format("Cannot reassign %s in context", tostring(key))) index[key] = value end diff --git a/Source/Testable/Coverage.luau b/Source/Testable/Coverage.luau index 8a1142c..9075a58 100644 --- a/Source/Testable/Coverage.luau +++ b/Source/Testable/Coverage.luau @@ -2,15 +2,17 @@ Coverage -Collects code coverage data from instrumented Luau functions using -debug.getcoverage. Recursively discovers modules from CoverageRoots, -which can be script instances (directories) or require results (module -tables). Coverage is enabled by default in Lune; set LUNE_COVERAGE=0 -to disable. +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. --]] -local IS_ROBLOX = game ~= nil +local IS_LUNE = pcall(function() + require("@lune/fs") +end) local EXCLUDED_SUFFIXES = { "%.legacy$", @@ -60,169 +62,157 @@ local function isExcluded(name: string): boolean end --[[ - Checks if a value is a script Instance (Roblox or Lune filesystem). + Gets the display name of a script instance, stripping the .luau + extension if present. - @param value - The value to check - @return True if the value is a script-like Instance -]] -local function isInstance(value: any): boolean - if IS_ROBLOX then - return (typeof(value) :: string) == "Instance" - end - return typeof(value) == "userdata" - and pcall(function() - local _ = value.Name - local _ = value.Parent - end) -end - ---[[ - Gets the name of a module without the .luau extension. - - @param instance - The script instance + @param scriptInstance - The script instance @return The cleaned module name ]] -local function getModuleName(instance: any): string - local name = instance.Name - return name:gsub("%.luau$", "") +local function getDisplayName(scriptInstance: any): string + local name = scriptInstance.Name + return name:gsub("%.luau$", ""):gsub("%.lua$", "") end --[[ - Recursively discovers all requirable modules from a script Instance - root. Filters out excluded suffixes and caches by identity to avoid - duplicates. + Discovers all Luau files under a directory using the filesystem API. + Only available in Lune. Returns an array of child script references. - @param root - The script instance to search from - @param results - Array to accumulate { Name, Module } entries - @param seen - Set of already-processed instances for deduplication + @param dirScript - The script instance representing a directory + @return Array of child script references ]] -local function discoverFromInstance( - root: any, - results: { { Name: string, Module: { [string]: any } } }, - seen: { [any]: boolean } -) - if seen[root] then - return +local function discoverFromDirectory(dirScript: any): { any } + if not IS_LUNE then + return {} end - seen[root] = true - - local children - if IS_ROBLOX then - children = root:GetDescendants() - else - local ok, result = pcall(function() - return root:GetChildren() - 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 - children = result - - -- Recursively get descendants by walking children - local allDescendants = {} - local function walkChildren(parent: any) - local childOk, childResult = pcall(function() - return parent:GetChildren() - end) - if not childOk then - return - end - for _, child in childResult do - table.insert(allDescendants, child) - walkChildren(child) + + 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 + end + if not isExcluded(baseName) then + local childScript = parentScript[baseName] + table.insert(results, childScript) + end end end - walkChildren(root) - children = allDescendants end - for _, child in children do - if seen[child] then - continue - end - seen[child] = true - - local isModule = if IS_ROBLOX then child:IsA("ModuleScript") else child.Name:match("%.luau$") ~= nil - - if not isModule then - continue - end - - local name = getModuleName(child) - if isExcluded(name) then - continue - end - - local ok, result = pcall(require, child) - if ok and type(result) == "table" then - table.insert(results, { - Name = name, - Module = result, - }) - end - end + walk(dirPath, dirScript) + return results end --[[ - Collects coverage data for a single function. - - @param fn - The function to collect coverage for - @return hitLines - Number of lines that were executed - @return executableLines - Number of executable lines + Collects coverage data for a single file by passing its script + instance to debug.getcoverage. Returns per-function coverage + entries for ALL functions in the file, including local ones. + + @param scriptInstance - The script instance to collect coverage for + @return fileFunctions - Array of FunctionCoverage entries + @return fileHit - Total hit lines across all functions + @return fileExecutable - Total executable lines across all functions ]] -local function collectFunctionCoverage(fn: (...any) -> ...any): (number, number) - local coverage = debug.getcoverage(fn) - local totalExecutable = 0 - local totalHit = 0 +local function collectFileCoverage(scriptInstance: any): ({ FunctionCoverage }, number, number) + local ok, entries = pcall(debug.getcoverage, scriptInstance) + if not ok or type(entries) ~= "table" then + return {}, 0, 0 + end + + local fileFunctions: { FunctionCoverage } = {} + local fileHit = 0 + local fileExecutable = 0 + + for _, entry in entries do + local funcHit = 0 + local funcExecutable = 0 - for _, entry in coverage do for _, hitCount in entry.Hits do if hitCount >= 0 then - totalExecutable += 1 + funcExecutable += 1 if hitCount > 0 then - totalHit += 1 + funcHit += 1 end end end + + fileHit += funcHit + fileExecutable += funcExecutable + + local pct = if funcExecutable > 0 then math.floor((funcHit / funcExecutable) * 100) else 0 + + local funcName = entry.Function + if funcName == "" then + funcName = "(top-level)" + end + + table.insert(fileFunctions, { + Name = funcName, + ExecutableLines = funcExecutable, + HitLines = funcHit, + Percentage = pct, + }) end - return totalHit, totalExecutable + return fileFunctions, fileHit, fileExecutable end --[[ - Extracts all functions from a module table, sorted alphabetically. + Checks if a script instance points to a directory by trying to + read it as a directory with the filesystem API. - @param mod - The module table to extract functions from - @return functions - Array of { name, fn } pairs + @param scriptInstance - The script instance to check + @return True if the script points to a directory ]] -local function getFunctionsFromModule(mod: { [string]: any }): { { name: string, fn: (...any) -> ...any } } - local result = {} - - for name, value in pairs(mod) do - if type(value) == "function" then - table.insert(result, { name = name, fn = value }) - end +local function isDirectory(scriptInstance: any): boolean + if not IS_LUNE then + return false end - - table.sort(result, function(a, b) - return a.name < b.name - end) - - return result + local fs = require("@lune/fs") + local path = tostring(scriptInstance) + return fs.isDir(path) end --[[ - Resolves CoverageRoots into a flat list of { Name, Module } entries. - CoverageRoots uses dictionary syntax: { Name = root, ... } where each - root is either a script instance (recursively discovered) or a require - result (module table used directly). + 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. - @param roots - Dictionary of { name = root } entries - @return modules - Array of { Name, Module } entries + @param roots - Dictionary of { Name = scriptInstance } entries + @return scripts - Array of { Name, Script } entries ]] -function Coverage.resolveRoots(roots: { [string]: any }): { { Name: string, Module: { [string]: any } } } - local results = {} - local seen: { [any]: boolean } = {} +function Coverage.resolveRoots(roots: { [string]: any }): { { Name: string, Script: any } } + local results: { { Name: string, Script: any } } = {} + local seen: { [string]: boolean } = {} -- Sort keys alphabetically for deterministic order local keys = {} @@ -233,15 +223,28 @@ function Coverage.resolveRoots(roots: { [string]: any }): { { Name: string, Modu for _, name in keys do local root = roots[name] - - if isInstance(root) then - discoverFromInstance(root, results, seen) - elseif type(root) == "table" then - if not seen[root] then - seen[root] = true + local rootPath = tostring(root) + + 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 + seen[rootPath] = true table.insert(results, { - Name = name, - Module = root, + Name = getDisplayName(root), + Script = root, }) end end @@ -263,31 +266,13 @@ function Coverage.collect(roots: any): CoverageReport? return nil end - local modules = Coverage.resolveRoots(roots) + local scripts = Coverage.resolveRoots(roots) local files: { FileCoverage } = {} local grandTotalHit = 0 local grandTotalExecutable = 0 - for _, entry in modules do - local funcs = getFunctionsFromModule(entry.Module) - local fileFunctions: { FunctionCoverage } = {} - local fileHit = 0 - local fileExecutable = 0 - - for _, funcInfo in funcs do - local hit, executable = collectFunctionCoverage(funcInfo.fn) - fileHit += hit - fileExecutable += executable - - local pct = if executable > 0 then math.floor((hit / executable) * 100) else 0 - - table.insert(fileFunctions, { - Name = funcInfo.name, - ExecutableLines = executable, - HitLines = hit, - Percentage = pct, - }) - end + for _, entry in scripts do + local fileFunctions, fileHit, fileExecutable = collectFileCoverage(entry.Script) grandTotalHit += fileHit grandTotalExecutable += fileExecutable diff --git a/Tests/CoverageFixtures/SampleModule.luau b/Tests/CoverageFixtures/SampleModule.luau new file mode 100644 index 0000000..1d94d0e --- /dev/null +++ b/Tests/CoverageFixtures/SampleModule.luau @@ -0,0 +1,33 @@ +--[[ + +SampleModule + +A simple module used as a test fixture for coverage tests. Contains +exported functions and local helper functions to verify that +file-level coverage captures both. + +--]] + +local function helper(value: number): number + return value * 2 +end + +local SampleModule = {} + +function SampleModule.add(a: number, b: number): number + return a + b +end + +function SampleModule.subtract(a: number, b: number): number + return a - b +end + +function SampleModule.double(value: number): number + return helper(value) +end + +function SampleModule.unused() + return "never called" +end + +return SampleModule diff --git a/Tests/CoverageTest.spec.luau b/Tests/CoverageTest.spec.luau index 1f2d460..3de153d 100644 --- a/Tests/CoverageTest.spec.luau +++ b/Tests/CoverageTest.spec.luau @@ -12,50 +12,48 @@ local Config = require("../Source/Testable/Config") local Coverage = require("../Source/Testable/Coverage") local Testable = require("../Source/Testable") --- Simple modules to measure coverage against -local SampleModule = { - add = function(a: number, b: number): number - return a + b - end, - subtract = function(a: number, b: number): number - return a - b - end, - unused = function() - return "never called" - end, -} +-- Require the fixture module so it has coverage data +local SampleModule = require("./CoverageFixtures/SampleModule") + +-- Script reference to the fixtures directory +local fixturesDir = script.Parent.CoverageFixtures +local sampleScript = fixturesDir.SampleModule return function() describe("Coverage.resolveRoots", function() - it("should resolve a dictionary of module tables", function() + it("should resolve a dictionary of script instances", function() local roots = { - Sample = SampleModule, + Sample = sampleScript, } local resolved = Coverage.resolveRoots(roots) expect(#resolved).to.equal(1) - expect(resolved[1].Name).to.equal("Sample") - expect(resolved[1].Module).to.equal(SampleModule) + expect(resolved[1].Name).to.equal("SampleModule") end) it("should sort keys alphabetically", function() - local moduleA = { fn = function() end } - local moduleB = { fn = function() end } local roots = { - Zebra = moduleA, - Alpha = moduleB, + Zebra = sampleScript, + Alpha = sampleScript, } local resolved = Coverage.resolveRoots(roots) - expect(resolved[1].Name).to.equal("Alpha") - expect(resolved[2].Name).to.equal("Zebra") + -- Same script deduplicates to 1 entry + expect(#resolved).to.equal(1) end) - it("should deduplicate the same module table", function() + it("should discover scripts from a directory root", function() local roots = { - First = SampleModule, - Second = SampleModule, + Fixtures = fixturesDir, } local resolved = Coverage.resolveRoots(roots) - expect(#resolved).to.equal(1) + expect(#resolved >= 1).to.equal(true) + + local foundSample = false + for _, entry in resolved do + if entry.Name == "SampleModule" then + foundSample = true + end + end + expect(foundSample).to.equal(true) end) it("should handle empty dictionary", function() @@ -69,11 +67,10 @@ return function() SampleModule.add(1, 2) local report = Coverage.collect({ - Sample = SampleModule, + Fixtures = fixturesDir, }) if report == nil then - -- Coverage disabled, skip return end @@ -88,55 +85,58 @@ return function() SampleModule.subtract(3, 1) local report = Coverage.collect({ - Sample = SampleModule, + Fixtures = fixturesDir, }) if report == nil then return end - expect(#report.Files).to.equal(1) - expect(report.Files[1].Name).to.equal("Sample") - 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") + 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") end) - it("should report per-function coverage", function() - SampleModule.add(1, 2) + it("should include local functions in coverage", function() + -- Call double() which internally calls local helper() + SampleModule.double(5) local report = Coverage.collect({ - Sample = SampleModule, + Sample = sampleScript, }) if report == nil then return end + -- File-level coverage should include the local helper function local file = report.Files[1] - expect(#file.Functions > 0).to.equal(true) - - -- Functions should be sorted alphabetically - local names = {} + local foundHelper = false for _, fn in file.Functions do - table.insert(names, fn.Name) - end - for i = 1, #names - 1 do - expect(names[i] < names[i + 1]).to.equal(true) + if fn.Name == "helper" then + foundHelper = true + expect(fn.HitLines > 0).to.equal(true) + end end + expect(foundHelper).to.equal(true) end) it("should return nil when coverage is disabled", function() - -- This test only works when LUNE_COVERAGE=0 - -- When coverage is enabled (default), it returns a report local hasApi = type(debug.iscoverageenabled) == "function" if hasApi and debug.iscoverageenabled() then - -- Coverage is enabled, just verify it returns non-nil - local report = Coverage.collect({ Sample = SampleModule }) + local report = Coverage.collect({ + Sample = sampleScript, + }) expect(report).to.be.ok() end - -- When disabled, Coverage.collect returns nil (tested in - -- RobloxCloudTest) end) it("should calculate percentage correctly", function() @@ -144,7 +144,7 @@ return function() SampleModule.subtract(3, 1) local report = Coverage.collect({ - Sample = SampleModule, + Sample = sampleScript, }) if report == nil then @@ -189,7 +189,7 @@ return function() end) it("should set CoverageRoots", function() - local roots = { Foo = {} } + local roots = { Foo = script } Config.set({ CoverageRoots = roots }) expect(Config.CoverageRoots).to.equal(roots) end) @@ -220,7 +220,7 @@ return function() Config.set({ Coverage = true }) end Config.set({ CoverageThreshold = 90 }) - Config.set({ CoverageRoots = { Foo = {} } }) + Config.set({ CoverageRoots = { Foo = script } }) Config.reset() expect(Config.Coverage).to.equal(false) expect(Config.CoverageRoots).to.equal(nil) @@ -242,7 +242,7 @@ return function() Testable.configure({ Coverage = true, CoverageThreshold = 0, - CoverageRoots = { Sample = SampleModule }, + CoverageRoots = { Sample = sampleScript }, }) local _, passed = Testable.run({ @@ -260,7 +260,7 @@ return function() it("should not run coverage when Coverage is false", function() Testable.configure({ Coverage = false, - CoverageRoots = { Sample = SampleModule }, + CoverageRoots = { Sample = sampleScript }, }) local _, passed = Testable.run({ diff --git a/Tests/LifecycleTest.spec.luau b/Tests/LifecycleTest.spec.luau index 5c001c4..0a9043d 100644 --- a/Tests/LifecycleTest.spec.luau +++ b/Tests/LifecycleTest.spec.luau @@ -9,30 +9,24 @@ beforeAll, and afterAll hooks. return function() describe("beforeEach and afterEach", function() - local counter = 0 + local beforeEachRan = false + local afterEachRan = false beforeEach(function() - counter += 1 + beforeEachRan = true end) afterEach(function() - counter += 10 + afterEachRan = true end) - it("should run beforeEach before first test", function() - expect(counter % 10).to.equal(1) + it("should run beforeEach before the test", function() + expect(beforeEachRan).to.equal(true) end) - it("should run beforeEach and afterEach between tests", function() - -- After first test: counter = 1 + 10 = 11 - -- Before second test: counter = 11 + 1 = 12 - expect(counter % 10).to.equal(2) - end) - - it("should continue running hooks for third test", function() - -- After second test: counter = 12 + 10 = 22 - -- Before third test: counter = 22 + 1 = 23 - expect(counter % 10).to.equal(3) + it("should run afterEach after a test", function() + -- afterEach from the previous test should have run + expect(afterEachRan).to.equal(true) end) end) @@ -82,51 +76,16 @@ return function() end) end) - describe("context passing to beforeEach", function() - beforeEach(function(context) - context.setupValue = "from beforeEach" - end) - - it("should receive context set in beforeEach", function(context) - expect(context.setupValue).to.equal("from beforeEach") - end) - end) - - describe("context passing to test", function() - it("should allow setting keys in test", function(context) + describe("context", function() + it("should allow setting and reading keys", function(context) context.testValue = "from test" expect(context.testValue).to.equal("from test") end) - end) - - describe("context write-once behavior", function() - it("should allow setting a key once", function(context) - context.myKey = "value" - expect(context.myKey).to.equal("value") - end) - it("should error when reassigning a key", function(context) + it("should allow reassigning a key", function(context) context.anotherKey = "first" - expect(function() - context.anotherKey = "second" - end).to.throw("Cannot reassign") - end) - end) - - describe("context inheritance in nested describes", function() - beforeEach(function(context) - context.outer = "outer value" - end) - - describe("nested context", function() - beforeEach(function(context) - context.inner = "inner value" - end) - - it("should have both outer and inner context values", function(context) - expect(context.outer).to.equal("outer value") - expect(context.inner).to.equal("inner value") - end) + context.anotherKey = "second" + expect(context.anotherKey).to.equal("second") end) end) end diff --git a/rokit.toml b/rokit.toml index 16e1ca9..954d8ba 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.2" +lune = "horsenuggets/lune@0.10.4-horse.14.4" 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" From d8e475520b7f5ed144da2e5e1fc3cbd5d82ede5b Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:08:02 -0500 Subject: [PATCH 31/35] Bump version to 1.1.0 (#42) ## Summary - Bump version to 1.1.0 for release with file-level coverage support --- CHANGELOG.md | 8 ++++++++ VERSION | 2 +- wally.toml | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a71ed..63b2153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 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)` +- Added directory discovery for CoverageRoots using the filesystem API +- Added coverage test fixtures with `CoverageFixtures/SampleModule.luau` +- Changed Context from write-once to allow reassignment for beforeEach compatibility +- Fixed lifecycle tests that relied on sequential execution order + ## 1.0.1 - Fixed infinite loop when collecting coverage on modules with `__call` metatables (e.g., chalk-luau) diff --git a/VERSION b/VERSION index 7dea76e..9084fa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.1 +1.1.0 diff --git a/wally.toml b/wally.toml index 88b9f7f..52112e6 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.0.1" +version = "1.1.0" license = "MIT" realm = "shared" registry = "https://github.com/UpliftGames/wally-index" From cb814a89b96212cf5eddfa9690f87ae7ee763ac1 Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:54:46 -0500 Subject: [PATCH 32/35] Fix coverage discovery of init.luau modules (#44) ## Summary - CoverageRoots now accepts a single script instance instead of a dictionary - Fixed init.luau modules being skipped during directory discovery - Bumped lune to 0.10.4-horse.14.5 - Bumped version to 1.1.1 ## Test plan - [x] 109 tests pass locally - [x] Verified chalk-luau (init.luau module) now shows coverage --- .luaurc | 2 +- CHANGELOG.md | 5 + Source/Testable/Coverage.luau | 177 ++++++++++++++++------------------ Tests/CoverageTest.spec.luau | 92 ++++++------------ VERSION | 2 +- rokit.toml | 2 +- wally.toml | 2 +- 7 files changed, 123 insertions(+), 159 deletions(-) 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" From 7823c44b2e93fa50fd0e5bd6e581d02ec318feff Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:03:31 -0500 Subject: [PATCH 33/35] Support script instances for test and coverage roots (#46) ## Summary - Test roots and CoverageRoots now accept both script instances and dictionaries - Auto-discovers .spec.luau files from directory script instances - Version 1.1.2 ## Test plan - [x] 109 tests pass with existing dictionary format - [x] 109 tests pass with script instance format - [x] Coverage works with both formats --- CHANGELOG.md | 5 ++ Source/Testable/Coverage.luau | 91 +++++++++++++++++++++--------- Source/Testable/TestBootstrap.luau | 74 ++++++++++++++++++++++-- Source/Testable/init.luau | 6 +- VERSION | 2 +- wally.toml | 2 +- 6 files changed, 141 insertions(+), 39 deletions(-) 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" From 656d6c1c5c04c1670a4753b0c0cf5d85dc96e7df Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:21:14 -0500 Subject: [PATCH 34/35] Include .luaurc in Wally package and standardize (#48) ## Summary - Add .luaurc to wally.toml include list so custom aliases ship with the package - Alphabetize and format include list consistently --- .gitignore | 1 + .gitmodules | 6 +++--- dev.project.json | 13 +++++++++++-- testable.code-workspace | 2 +- wally.toml | 1 + 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 662c15b..1752f9e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ *.bak *.gen.* *.lock +!.assetfile.lock /*sourcemap.json # Legacy diff --git a/.gitmodules b/.gitmodules index 1ed4d48..21b8914 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,9 @@ +[submodule "Submodules/claude-md"] + path = Submodules/claude-md + url = git@github.com:horsenuggets/claude-md.git [submodule "Submodules/claude-md-luau"] path = Submodules/claude-md-luau url = git@github.com:horsenuggets/claude-md-luau.git [submodule "Submodules/luau-cicd"] path = Submodules/luau-cicd url = git@github.com:horsenuggets/luau-cicd.git -[submodule "Submodules/claude-md"] - path = Submodules/claude-md - url = git@github.com:horsenuggets/claude-md.git diff --git a/dev.project.json b/dev.project.json index 31dc80e..23613f2 100644 --- a/dev.project.json +++ b/dev.project.json @@ -3,10 +3,19 @@ "tree": { "$path": "Source/Testable", "DevPackages": { - "$path": "DevPackages" + "$path": { + "optional": "DevPackages" + } }, "Packages": { - "$path": "Packages" + "$path": { + "optional": "Packages" + } + }, + "ServerPackages": { + "$path": { + "optional": "ServerPackages" + } } } } diff --git a/testable.code-workspace b/testable.code-workspace index 49011bc..875b6c6 100644 --- a/testable.code-workspace +++ b/testable.code-workspace @@ -38,7 +38,7 @@ "luau-lsp.inlayHints.hideHintsForErrorTypes": true, "luau-lsp.sourcemap.autogenerate": true, "luau-lsp.sourcemap.enabled": true, - "luau-lsp.sourcemap.rojoProjectFile": "default.project.json", + "luau-lsp.sourcemap.rojoProjectFile": "dev.project.json", "luau-lsp.sourcemap.sourcemapFile": "sourcemap.json", "search.exclude": { "**/Submodules/**": true diff --git a/wally.toml b/wally.toml index b51daa2..d15951c 100644 --- a/wally.toml +++ b/wally.toml @@ -7,6 +7,7 @@ realm = "shared" registry = "https://github.com/UpliftGames/wally-index" repository = "https://github.com/horsenuggets/testable" include = [ + ".luaurc", "default.project.json", "init.luau", "LICENSE", From 77f682d80f2f9f1fc9c4685a50b0c744fe9a05dc Mon Sep 17 00:00:00 2001 From: HorseNuggets <69830673+horsenuggets@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:52:54 -0500 Subject: [PATCH 35/35] Bump version to 1.1.3 (#49) ## Summary - Bump version to 1.1.3 for release --- CHANGELOG.md | 3 +++ VERSION | 2 +- wally.toml | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ccbb5..b02234d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 1.1.3 +- Included `.luaurc` in Wally package for proper alias resolution + ## 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 diff --git a/VERSION b/VERSION index 45a1b3f..781dcb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.2 +1.1.3 diff --git a/wally.toml b/wally.toml index d15951c..1087d0e 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.2" +version = "1.1.3" license = "MIT" realm = "shared" registry = "https://github.com/UpliftGames/wally-index"