diff --git a/.github/workflows/run_tests_runnerimprovements.yml b/.github/workflows/run_tests_runnerimprovements.yml new file mode 100644 index 0000000..99c0760 --- /dev/null +++ b/.github/workflows/run_tests_runnerimprovements.yml @@ -0,0 +1,169 @@ +--- +name: GLuaTest Tester + + +on: + workflow_call: + inputs: + server-cfg: + type: string + required: false + description: "A path (relative to project directory) with extra server config options" + default: "gluatest_custom.cfg" + + requirements: + type: string + required: false + description: "A path (relative to project directory) with a list of all requirements to test this project" + default: "gluatest_requirements.txt" + + gamemode: + type: string + required: false + description: "The name of the gamemode for the test server to run. (Be sure to list it as a requirement or include it in the test collection, if necessary)" + default: "sandbox" + + collection: + type: string + required: false + description: "The workshop ID of the collection for the test server to use" + + extra-startup-args: + type: string + required: false + description: "Extra arguments to pass to the test server on startup" + default: "" + + ssh-private-key: + type: string + required: false + description: "The Private SSH key to use when cloning the dependencies" + + github-token: + type: string + required: false + description: "A GitHub Personal Access Token" + + timeout: + type: string + required: false + description: "How many minutes to let the job run before killing the server (small projects shouldn't need more than 2 minutes)" + default: "2" + + branch: + type: string + required: false + description: "Which GMod branch to run your tests on. Must be: 'live' or 'x86-64'" + default: "live" + + gluatest-ref: + type: string + required: false + description: "With tag/branch of GLuaTest to run" + default: "main" + + custom-overrides: + type: string + required: false + description: "An absolute path with custom files to copy to the server directly. Structure should match the contents of `garrysmod/`" + +jobs: + test: + name: "Run tests" + runs-on: ubuntu-latest + + steps: + - name: "Check out the repo" + uses: actions/checkout@v4 + with: + path: project + + - name: Set up output files + run: | + cd $GITHUB_WORKSPACE + touch $GITHUB_WORKSPACE/project/${{ inputs.requirements }} + echo "gluatest_github_output 1" >> $GITHUB_WORKSPACE/project/${{ inputs.server-cfg }} + + - name: Get latest GLuaTest version + id: latest-tag-getter + run: | + cd $GITHUB_WORKSPACE + + git clone --single-branch --branch ${{ inputs.gluatest-ref }} --depth 1 https://github.com/CFC-Servers/GLuaTest.git gluatest + + git fetch --quiet --tags + latest="$(git describe --tags `git rev-list --tags --max-count=1`)" + echo "Latest Tag: $latest" + echo "LATEST_TAG=$latest" >> $GITHUB_OUTPUT + + cd $GITHUB_WORKSPACE + + - name: Prepare the override directory + run: | + cd $GITHUB_WORKSPACE/project + + get_gamemode_name () { + gamemode_file=$(grep --recursive --word-regexp --files-with-matches '"base"') + gamemode_name=$(head --quiet --lines 1 "$gamemode_file" | tr --delete '"') + + echo "$gamemode_name" + } + + source="$GITHUB_WORKSPACE/project" + dest="$GITHUB_WORKSPACE/garrysmod_override/" + + if [ -d "garrysmod" ]; then + # The repo contains a full server + source="$GITHUB_WORKSPACE/project/garrysmod" + elif [ -d "gamemodes" ]; then + # The repo is the contents of a garrysmod/ dir - we can copy its contents directly + : + elif [ -d "gamemode" ]; then + # The repo is the contents of a gamemode + gamemode_name=$(get_gamemode_name) + dest="$GITHUB_WORKSPACE/garrysmod_override/gamemodes/$gamemode_name/" + elif [ -d "lua" ]; then + # The repo is likely an addon + dest="$GITHUB_WORKSPACE/garrysmod_override/addons/project/" + else + echo "::error title=Unknown project structure!::Please report this: https://github.com/CFC-Servers/GLuaTest/issues" + exit 1 + fi + + mkdir --verbose --parents "$dest" + cp --recursive --verbose $source/* "$dest/" + + - name: Sync custom overrides + if: ${{ inputs.custom-overrides }} + run: | + rsync --verbose --archive ${{ inputs.custom-overrides }} $GITHUB_WORKSPACE/garrysmod_override/ + + - name: Build GLuaTest + env: + REQUIREMENTS: "${{ github.workspace }}/project/${{ inputs.requirements }}" + CUSTOM_SERVER_CONFIG: "${{ github.workspace }}/project/${{ inputs.server-cfg }}" + PROJECT_DIR: "${{ github.workspace }}/garrysmod_override" + + run: | + cd $GITHUB_WORKSPACE/gluatest/docker + docker build --build-arg="GMOD_BRANCH=${{ inputs.branch }}" --build-arg="GLUATEST_REF=${{ inputs.gluatest-ref }}" --tag ghcr.io/cfc-servers/gluatest:latest . + + - name: Run GLuaTest + env: + REQUIREMENTS: "${{ github.workspace }}/project/${{ inputs.requirements }}" + CUSTOM_SERVER_CONFIG: "${{ github.workspace }}/project/${{ inputs.server-cfg }}" + PROJECT_DIR: "${{ github.workspace }}/garrysmod_override" + EXTRA_STARTUP_ARGS: "${{ inputs.extra-startup-args }}" + GAMEMODE: "${{ inputs.gamemode }}" + COLLECTION_ID: "${{ inputs.collection }}" + SSH_PRIVATE_KEY: "${{ inputs.ssh-private-key }}" + GITHUB_TOKEN: "${{ inputs.github-token }}" + TIMEOUT: "${{ inputs.timeout }}" + + run: | + docker compose up --pull never --no-log-prefix --exit-code-from runner + exitstatus=$? + + if [ $exitstatus -ne 0 ]; then + exit $exitstatus + fi diff --git a/LLM_PROMPT.md b/LLM_PROMPT.md new file mode 100644 index 0000000..5da7170 --- /dev/null +++ b/LLM_PROMPT.md @@ -0,0 +1,264 @@ +GLuaTest is a testing framework for Garry's Mod Lua (GLua) projects, designed to make writing automated tests intuitive and efficient. + +--- + +**Test Structure:** + +- Each test file returns a **Test Group**, which is a table containing: + - `groupName` (optional): Name of the test group. + - `cases` (required): Table of **Test Cases**. + - `beforeAll` (optional): Function executed once before all test cases. + - `beforeEach` (optional): Function executed before each test case. + - `afterEach` (optional): Function executed after each test case. + - `afterAll` (optional): Function executed once after all test cases. + +**Test Case Structure:** + +- Each Test Case is a table with: + - `name` (required): Description of the test. + - `func` (required): Function containing the test logic. + - `async` (optional, default `false`): Set to `true` for asynchronous tests. + - `timeout` (optional, default `60`): Time in seconds before an async test times out. (Keep this as low as possible) + - `cleanup` (optional): Function executed after the test case, even if it fails. + - `when` (optional): Boolean or function; test runs only if `true`. + - `skip` (optional): Boolean or function; test is skipped if `true`. + +--- + +**Expectations:** + +Use the `expect` function to make assertions. + +- **Equality:** + - `expect( actual ).to.equal( expected )` + - `expect( actual ).to.aboutEqual( expected, tolerance? )` +- **Comparisons:** + - `expect( actual ).to.beLessThan( value )` + - `expect( actual ).to.beGreaterThan( value )` + - `expect( actual ).to.beBetween( min, max )` +- **Type Checks:** + - `expect( actual ).to.beA( "type" )` or `expect( actual ).to.beAn( "type" )` +- **Existence:** + - `expect( actual ).to.exist()` + - `expect( actual ).to.beNil()` +- **Validity:** + - `expect( entity ).to.beValid()` + - `expect( entity ).to.beInvalid()` +- **Boolean Values:** + - `expect( actual ).to.beTrue()` + - `expect( actual ).to.beFalse()` +- **Errors:** + - `expect( function ).to.err()` + - `expect( function ).to.errWith( "error message" )` + - `expect( function ).to.succeed()` +- **Function Calls (with stubs, or functions):** + - `expect( funcStub ).was.called()` + - `expect( funcStub ).wasNot.called()` + +When passing functions to `expect`, you may add parameters that get passed into the subject function: +``` +expect( func, param1, param2 ).to.succeed() -- Calls func(param1, param2) +``` + +--- + +**Negation:** + +- Use `.notTo` or `.toNot` to negate expectations: + - `expect( actual ).notTo.equal( value )` + - `expect( entity ).toNot.beValid()` + +--- + +**Stubs:** + +- Replace functions to control behavior during tests. +- **Creating a Stub:** + - `local myStub = stub( tbl, "functionName" ) -- Stubs tbl.functionName` +- **Specifying Return Values:** + - `.returns( value )`: Always returns `value`. + - `.with( func )`: Uses a custom function. + - `.returnsSequence( { values }, default )`: Returns values in sequence. +- **Checking Calls:** + - `expect( myStub ).was.called()` + - `expect( myStub ).wasNot.called()` +- **Restoring Original Function (this is done automatically when the test finishes):** + - `myStub:Restore()` + +--- + +**Asynchronous Tests:** + +- Set `async = true` in the test case. +- Use `done()` to signal completion. +- Use `fail()` or `fail( "custom message" )` to manually fail. +- Example: + + ```lua + { + name = "Async Test Example", + async = true, + timeout = 1.25, -- Keep the timeout as low as possible + func = function() + timer.Simple( 1, function() + expect( true ).to.beTrue() + done() + end ) + end + } + ``` + +--- + +**State Management:** + +- Use `state` table to share data between `beforeEach`, `func`, `cleanup`, and `afterEach`. +- Store data in the `state` when you need to reliably clean it up/undo it after the test. +- Example: + + ```lua + beforeEach = function( state ) + local ent = ents.Create( "prop_physics" ) + ent:Spawn() + + state.ent = ent + end, + afterEach = function( state ) + if IsValid( state.ent ) then + SafeRemoveEntity( state.ent ) + end + end + + cases = { + { + name = "State Example", + func = function( state ) + state.originalValue = _G.SomeGlobal + _G.SomeGlobal = 42 + + local ent = state.ent + expect( ent:GetClass() ).to.equal( "prop_physics" ) + + local extraEnt = ents.Create( "prop_physics" ) + extraEnt:Spawn() + state.extraEnt = extraEnt + expect( extraEnt:GetClass() ).to.equal( "prop_physics" ) + + -- Don't clean up `SomeGlobal` or `extraEnt` here because if any `expect` fails (or something errors), nothing after it will run + -- Do it in the `cleanup` function instead + end, + cleanup = function( state ) + _G.SomeGlobal = state.originalValue + + if IsValid( state.extraEnt ) then + SafeRemoveEntity( state.extraEnt ) + end + end + } + } + ``` + +--- + +**Example Test Group:** + +```lua +return { + groupName = "Example Tests", + + beforeEach = function( state ) + state.player = Player( 1 ) -- Convenience, does not need cleanup + + -- file.Write( "test.txt", "Hello, world!" ) This is only needed for a single test, it should be in that test instead + end, + + afterEach = function( state ) + -- file.Delete( "test.txt" ) if you did file.Write in beforeEach, you should do file.Delete here + end, + + cases = { + { + name = "Check Player Validity", + func = function(state) + expect(state.player).to.beValid() + end + }, + { + name = "Function Should Succeed", + func = function() + local result = SomeFunction() + expect(result).to.succeed() + end + }, + { + name = "Checker returns true when file exists", + func = function() + file.Write( "test.txt", "Hello, world!" ) + + local exists = file.Exists( "test.txt", "DATA" ) + expect( exists ).to.beTrue() + end, + cleanup = function() + file.Delete( "test.txt" ) -- Clean it up here instead of in the test func + end + { + name = "Stub Example", + func = function() + -- You do not need to restore the stub manually in cases like this (it's done automatically) + local myStub = stub( SomeModule, "FunctionName" ).returns( true ) + expect( SomeModule.FunctionName() ).to.beTrue() + expect( myStub ).was.called() + end + }, + { + name = "Stub restoration example", + func = function() + local printStub = stub( _G, "print" ).returns( true ) + expect( SomeModule.FunctionThatCallsPrint() ).to.beTrue() + expect( printStub ).was.called() + + -- But now I need it to actually print again, so I have to manually Restore the stub + printStub:Restore() + + expect( SomeModule.AnotherFunctionThatCallsPrint() ).to.beTrue() + end + }, + { + name = "Conditional Test", + when = system.IsLinux(), + func = function() + expect(system.IsLinux()).to.beTrue() + end + }, + { + name = "Skipped Test", + skip = true, + func = function() + -- This test will be skipped + end + } + } +} +``` + +--- + +**Best Practices:** + +- **Isolation:** Use stubs to isolate the unit under test. +- **Cleanup:** Ensure any changes made during tests are reverted. +- **Avoid Side Effects:** Tests should not affect global state or other tests. +- **Clarity:** Use descriptive names and clear assertions. +- **State Sharing:** Use `state` to pass data between setup, test, and teardown functions. + +--- + +**Caveats:** + +- **No External Functions:** Do not use functions not provided by GLuaTest. +- **Automatic Stub Restoration:** Stubs are automatically restored after each test; manual restoration is rarely needed. +- **Async Tests Must Signal Completion:** Always call `done()` or `fail()` in async tests. + +--- + +By following this guide, you can write effective GLuaTest test suites for GLua code, utilizing the framework's full feature set correctly. diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 46816ec..703c11d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -27,4 +27,6 @@ services: - COLLECTION_ID=$COLLECTION_ID - SSH_PRIVATE_KEY=$SSH_PRIVATE_KEY - GITHUB_TOKEN=$GITHUB_TOKEN - - TIMEOUT=${TIMEOUT-5} + - TIMEOUT=$TIMEOUT + - MAP=$MAP + - EXTRA_STARTUP_ARGS=$EXTRA_STARTUP_ARGS diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index c015176..6492b46 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -14,13 +14,16 @@ git clone --depth 1 https://github.com/CFC-Servers/GLuaTest.git _tmp_https &> /d rm -rf _tmp_ssh _tmp_https # Copy the overrides overtop the server files -rsync --archive $home/garrysmod_override/ $server/ +echo "Copying serverfiles overrides..." +rsync --verbose --archive $home/serverfiles_override/ $gmodroot/ if [ -f "$gmodroot/custom_requirements.txt" ]; then + echo "Appending custom requirements" cat "$gmodroot/custom_requirements.txt" >> "$gmodroot/requirements.txt" fi if [ -f "$gmodroot/custom_server.cfg" ]; then + echo "Appending custom server configs" cat "$gmodroot/custom_server.cfg" >> "$server/cfg/test.cfg" fi @@ -36,8 +39,6 @@ if [[ ! -z "$SSH_PRIVATE_KEY" ]]; then ssh-add - <<< "$SSH_PRIVATE_KEY" fi -cd "$server"/addons - function getCloneLine { python3 - <<-EOF line = "$1" @@ -66,8 +67,9 @@ print("git clone -v --depth 1 " + url + branch + " --single-branch " + name) EOF } +cd "$server"/addons while read p; do - echo "$p" + echo "Handling requirement: $p" if [[ -z "$SSH_PRIVATE_KEY" ]]; then eval $(getCloneLine "$p" ) else @@ -77,9 +79,10 @@ done <"$gmodroot"/requirements.txt gamemode="${GAMEMODE:-sandbox}" collection="${COLLECTION_ID:-0}" +map="${MAP:-gm_construct}" echo "Starting the server with gamemode: $gamemode" -srcds_args=( +base_srcds_args=( # Test requirements -systemtest # Allows us to exit the game from inside Lua -condebug # Logs everything to console.log @@ -104,6 +107,10 @@ srcds_args=( -high # Sets "high" process affinity -threads 6 # Double the allocated threads to the threadpool + -maxplayers 12 + -disableluarefresh + +mat_dxlevel 1 + # Game setup -game garrysmod -ip 127.0.0.1 @@ -111,21 +118,19 @@ srcds_args=( +clientport 27005 +gamemode "$gamemode" +host_workshop_collection "$collection" - +map gm_construct - -maxplayers 12 + +map "$map" +servercfgfile test.cfg - -disableluarefresh - +mat_dxlevel 1 ) +srcds_args="${base_srcds_args[@]} $EXTRA_STARTUP_ARGS" echo "GMOD_BRANCH: $GMOD_BRANCH" if [ "$GMOD_BRANCH" = "x86-64" ]; then echo "Starting 64-bit server" - unbuffer timeout "$timeout" "$gmodroot"/srcds_run_x64 "${srcds_args[@]}" + unbuffer timeout "$timeout" "$gmodroot"/srcds_run_x64 "$srcds_args" else echo "Starting 32-bit server" - unbuffer timeout "$timeout" "$gmodroot"/srcds_run "${srcds_args[@]}" + unbuffer timeout "$timeout" "$gmodroot"/srcds_run "$srcds_args" fi status=$? diff --git a/lua/gluatest/expectations/expect.lua b/lua/gluatest/expectations/expect.lua index f7c76b7..3a0e7bc 100644 --- a/lua/gluatest/expectations/expect.lua +++ b/lua/gluatest/expectations/expect.lua @@ -1,10 +1,14 @@ +--- @type fun(subject: any, vararg): GLuaTest_PositiveExpectations local makePositive = include( "gluatest/expectations/positive.lua" ) + +--- @type fun(subject: any, vararg): GLuaTest_NegativeExpectations local makeNegative = include( "gluatest/expectations/negative.lua" ) return function( subject, ... ) local positive = makePositive( subject, ... ) local negative = makeNegative( subject, ... ) + --- @class GLuaTest_Expect local expect = { to = positive, notTo = negative, diff --git a/lua/gluatest/expectations/negative.lua b/lua/gluatest/expectations/negative.lua index 48cf452..7f76917 100644 --- a/lua/gluatest/expectations/negative.lua +++ b/lua/gluatest/expectations/negative.lua @@ -1,35 +1,70 @@ local type = type +local TypeID = TypeID local IsValid = IsValid +local isstring = isstring local string_format = string.format +local GetDiff = include( "utils/table_diff.lua" ) -- Inverse checks return function( subject, ... ) + -- Args that are passed after the subject, i.e. expect( subject, arg1, arg2 ) local args = { ... } + -- Wrap the subject in quotes if if's a string + local fmtPrefix = "Expectation Failed: Expected %s " + if isstring( subject ) then + fmtPrefix = string.Replace( fmtPrefix, "%s", "'%s'" ) + end + + --- @class GLuaTest_NegativeExpectations local expectations = { expected = function( suffix, ... ) - local fmt = "Expectation Failed: Expected %s " .. suffix + local fmt = fmtPrefix .. suffix local message = string_format( fmt, subject, ... ) error( message ) end } + local i = expectations + --- Expects the subject is not equal to the comparison + --- @param comparison any function expectations.equal( comparison ) if subject == comparison then i.expected( "to not equal '%s'", comparison ) end end + --- @deprecated + --- @param comparison any function expectations.eq( comparison ) GLuaTest.DeprecatedNotice( "toNot.eq( value )", "toNot.equal( value )" ) return expectations.equal( comparison ) end - function expectations.aboutEqual( comparison ) - local tolerance = args[1] or 0.00001 + --- Expects the subject table is not deeply equal to the comparison + --- @param comparison table + function expectations.deepEqual( comparison ) + assert( TypeID( subject ) == TYPE_TABLE, ".deepEqual expects a table" ) + assert( TypeID( comparison ) == TYPE_TABLE, ".deepEqual expects a table" ) + + local diff = GetDiff( subject, comparison ) + + if not diff then + i.expected( "to not deeply equal '%s' - found identical contents", comparison ) + end + end + + --- Expects the subject is not approximately equal to the comparison, with a tolerance + --- @param comparison number + --- @param tolerance? number Tolerance for the comparison + function expectations.aboutEqual( comparison, tolerance ) + assert( TypeID( subject ) == TYPE_NUMBER, ".aboutEqual expects a number" ) + assert( TypeID( comparison ) == TYPE_NUMBER, ".aboutEqual expects a number" ) + + tolerance = tolerance or 0.00001 local difference = math.abs( subject - comparison ) if difference <= tolerance then @@ -37,60 +72,75 @@ return function( subject, ... ) end end + --- Expects the subject is not less than the comparison + --- @param comparison any function expectations.beLessThan( comparison ) if subject < comparison then i.expected( "to not be less than '%s'", comparison ) end end + --- Expects the subject is not greater than the comparison + --- @param comparison any function expectations.beGreaterThan( comparison ) if subject > comparison then i.expected( "to not be greater than '%s'", comparison ) end end + --- Expects the subject is not between the lower and upper bounds + --- @param lower any + --- @param upper any function expectations.beBetween( lower, upper ) if subject >= lower and subject <= upper then i.expected( "to not be between '%s' and '%s'", lower, upper ) end end + --- Expects the subject is not exactly equal to true function expectations.beTrue() if subject == true then i.expected( "to not be true" ) end end + --- Expects the subject is not exactly equal to false function expectations.beFalse() if subject == false then i.expected( "to not be false" ) end end + --- Expects the subject to not pass an IsValid check function expectations.beValid() if IsValid( subject ) then i.expected( "to not be valid" ) end end + --- Expects the subject to not fail an IsValid check function expectations.beInvalid() if not IsValid( subject ) then i.expected( "to not be invalid" ) end end + --- Expects the subject to not be nil function expectations.beNil() if subject == nil then i.expected( "to not be nil" ) end end + --- Expects the subject to be nil function expectations.exist() if subject ~= nil then i.expected( "to not exist" ) end end + --- Expects the subject to not be of the given type + --- @param comparison string function expectations.beA( comparison ) local class = type( subject ) @@ -99,6 +149,7 @@ return function( subject, ... ) end end + --- Expect the subject to not be of the given type function expectations.beAn( comparison ) local class = type( subject ) @@ -107,7 +158,10 @@ return function( subject, ... ) end end + --- Expects the subject function to not run succesfully function expectations.succeed() + assert( TypeID( subject ) == TYPE_FUNCTION, ".succeed expects a function" ) + local success = pcall( subject, unpack( args ) ) if success ~= false then @@ -115,7 +169,10 @@ return function( subject, ... ) end end + --- Expects the subject function to not fail when run function expectations.err() + assert( TypeID( subject ) == TYPE_FUNCTION, ".err expects a function" ) + local success = pcall( subject, unpack( args ) ) if success ~= true then @@ -123,13 +180,18 @@ return function( subject, ... ) end end + --- Expects the subject function to fail when run, and not produce the given error + --- @param comparison string function expectations.errWith( comparison ) + assert( TypeID( subject ) == TYPE_FUNCTION, ".errWith expects a function" ) + assert( isstring( comparison ), "errWith expects a string" ) + local success, err = pcall( subject, unpack( args ) ) if success == true then i.expected( "to error" ) else - if string.StartWith( err, "lua/" ) or string.StartWith( err, "addons/" ) then + if string.StartsWith( err, "lua/" ) or string.StartsWith( err, "addons/" ) then local _, endOfPath = string.find( err, ":%d+: ", 1 ) assert( endOfPath, "Could not find end of path in error message: " .. err ) @@ -142,9 +204,10 @@ return function( subject, ... ) end end - -- An important distinction between this and the Positive version: - -- the Positive expectation lets you specify how many times it - -- should have been called, but this one does not + --- Expects the subject stub to not have been called + --- An important distinction between this and the Positive version: + --- the Positive expectation lets you specify how many times it + --- should have been called, but this one does not function expectations.called() local callCount = subject.callCount if callCount > 0 then diff --git a/lua/gluatest/expectations/positive.lua b/lua/gluatest/expectations/positive.lua index b78b78e..fc51f2b 100644 --- a/lua/gluatest/expectations/positive.lua +++ b/lua/gluatest/expectations/positive.lua @@ -1,35 +1,70 @@ local type = type +local TypeID = TypeID local IsValid = IsValid +local isstring = isstring local string_format = string.format +local GetDiff = include( "utils/table_diff.lua" ) -- Positive checks return function( subject, ... ) + -- Args that are passed after the subject, i.e. expect( subject, arg1, arg2 ) local args = { ... } + -- Wrap the subject in quotes if if's a string + local fmtPrefix = "Expectation Failed: Expected %s " + if isstring( subject ) then + fmtPrefix = string.Replace( fmtPrefix, "%s", "'%s'" ) + end + + --- @class GLuaTest_PositiveExpectations local expectations = { + --- Handles the error message for the expectation failure expected = function( suffix, ... ) - local fmt = "Expectation Failed: Expected %s " .. suffix + local fmt = fmtPrefix .. suffix local message = string_format( fmt, subject, ... ) error( message ) end } + local i = expectations + --- Expects the subject is exactly equal to the comparison + --- @param comparison any function expectations.equal( comparison ) if subject ~= comparison then i.expected( "to equal '%s'", comparison ) end end + --- @deprecated + --- @param comparison any function expectations.eq( comparison ) GLuaTest.DeprecatedNotice( "to.eq( value )", "to.equal( value )" ) return expectations.equal( comparison ) end - function expectations.aboutEqual( comparison ) - local tolerance = args[1] or 0.00001 + --- Expects the subject table is deeply equal to the comparison + --- @param comparison table + function expectations.deepEqual( comparison ) + assert( TypeID( subject ) == TYPE_TABLE, "deepEqual expects a table" ) + assert( TypeID( comparison ) == TYPE_TABLE, "deepEqual expects a table" ) + + local diff, path = GetDiff( subject, comparison ) + + if diff then + i.expected( "to deeply equal '%s' - found a difference at '%s'", comparison, path ) + end + end + + --- Expects the subject is approximately equal to the comparison, with a tolerance + --- @param comparison number + --- @param tolerance? number Tolerance for the comparison + function expectations.aboutEqual( comparison, tolerance ) + assert( TypeID( subject ) == TYPE_NUMBER, ".aboutEqual expects a number" ) + + tolerance = tolerance or 0.00001 local difference = math.abs( subject - comparison ) if difference > tolerance then @@ -37,62 +72,75 @@ return function( subject, ... ) end end + --- Expects the subject is less than the comparison + --- @param comparison any function expectations.beLessThan( comparison ) - if subject >= comparison then i.expected( "to be less than '%s'", comparison ) end end + --- Expects the subject is greater than the comparison + --- @param comparison any function expectations.beGreaterThan( comparison ) - if subject <= comparison then i.expected( "to be greater than '%s'", comparison ) end end + --- Expects the subject is between the lower and upper bounds + --- @param lower any + --- @param upper any function expectations.beBetween( lower, upper ) if subject < lower or subject > upper then i.expected( "to be between '%s' and '%s'", lower, upper ) end end + --- Expects the subject is exactly equal to true function expectations.beTrue() if subject ~= true then i.expected( "to be true" ) end end + --- Check if the subject is exactly equal to false function expectations.beFalse() if subject ~= false then i.expected( "to be false" ) end end + --- Expects the subject to pass an IsValid check function expectations.beValid() if not IsValid( subject ) then i.expected( "to be valid" ) end end + --- Expects the subject to fail an IsValid check function expectations.beInvalid() if IsValid( subject ) then i.expected( "to be invalid" ) end end + --- Expects the subject to be nil function expectations.beNil() if subject ~= nil then i.expected( "to be nil" ) end end + --- Expects the subject to not be nil function expectations.exist() if subject == nil then i.expected( "to exist, got nil" ) end end + --- Expects the subject to be of the given type + --- @param comparison string function expectations.beA( comparison ) local class = type( subject ) @@ -100,9 +148,20 @@ return function( subject, ... ) i.expected( "to be a '%s'", comparison ) end end - expectations.beAn = expectations.beA + --- Expect the subject to be of the given type + function expectations.beAn( comparison ) + local class = type( subject ) + + if class ~= comparison then + i.expected( "to not be an '%s'", comparison ) + end + end + + --- Expects the subject function to run succesfully function expectations.succeed() + assert( TypeID( subject ) == TYPE_FUNCTION, ".succeed expects a function" ) + local success, err = pcall( subject, unpack( args ) ) if success == false then @@ -110,7 +169,10 @@ return function( subject, ... ) end end + --- Expects the subject function to fail when run function expectations.err() + assert( TypeID( subject ) == TYPE_FUNCTION, ".err expects a function" ) + local success = pcall( subject, unpack( args ) ) if success == true then @@ -118,13 +180,18 @@ return function( subject, ... ) end end + --- Expects the subject function to fail when run, and produce the given error + --- @param comparison string function expectations.errWith( comparison ) + assert( TypeID( subject ) == TYPE_FUNCTION, ".errWith expects a function" ) + assert( TypeID( comparison ) == TYPE_STRING, ".errWith expects a string" ) + local success, err = pcall( subject, unpack( args ) ) if success == true then i.expected( "to error with '%s'", comparison ) else - if string.StartWith( err, "lua/" ) or string.StartWith( err, "addons/" ) then + if string.StartsWith( err, "lua/" ) or string.StartsWith( err, "addons/" ) then local _, endOfPath = string.find( err, ":%d+: ", 1 ) assert( endOfPath, "Could not find end of path in error message: " .. err ) @@ -137,7 +204,11 @@ return function( subject, ... ) end end + --- Expects the subject stub to have been called, optionally with an expected number of calls + --- @param n? number function expectations.called( n ) + assert( subject.IsStub, ".called expects a stub" ) + local callCount = subject.callCount if n == nil then diff --git a/lua/gluatest/expectations/utils/table_diff.lua b/lua/gluatest/expectations/utils/table_diff.lua new file mode 100644 index 0000000..f821b50 --- /dev/null +++ b/lua/gluatest/expectations/utils/table_diff.lua @@ -0,0 +1,52 @@ +local TypeID = TypeID +local TYPE_TABLE = TYPE_TABLE +local TYPE_STRING = TYPE_STRING + +local function stringifyKey( key ) + if TypeID( key ) == TYPE_STRING then + -- check if it is a key that we can do a dot access on + if key:match( "^[%a_][%w_]*$" ) then + return "." .. key + end + + key = "\"" .. key:gsub( "\"", "\\\"" ) .. "\"" + end + + return "[" .. key .. "]" +end + +local function GetDiff( t1, t2, path ) + path = path or "tableA" + if t1 == t2 then + return false + end + + for k, v in pairs( t1 ) do + local currentPath = path .. stringifyKey( k ) + + -- Key is missing from k2 + if t2[k] == nil then + return true, currentPath + end + + if TypeID( v ) == TYPE_TABLE and TypeID( t2[k] ) == TYPE_TABLE then + local isDifferent, diffPath = GetDiff( v, t2[k], currentPath ) + if isDifferent then + return true, diffPath + end + elseif v ~= t2[k] then + return true, currentPath + end + end + + -- Extra key in t2 + for k in pairs( t2 ) do + if t1[k] == nil then + return true, path .. stringifyKey( k ) + end + end + + return false +end + +return GetDiff diff --git a/lua/gluatest/init.lua b/lua/gluatest/init.lua index 6a4b2ef..ef2c343 100644 --- a/lua/gluatest/init.lua +++ b/lua/gluatest/init.lua @@ -1,7 +1,8 @@ local RED = Color( 255, 0, 0 ) +--- @class GLuaTest GLuaTest = { - -- If, for some reason, you need to run GLuaTest clientside, set this to true + -- If, for some reason, you need to run GLuaTest clientside, set this to true (not very well supported) RUN_CLIENTSIDE = false, DeprecatedNotice = function( old, new ) @@ -28,24 +29,39 @@ if GLuaTest.RUN_CLIENTSIDE then AddCSLuaFile( "gluatest/runner/msgc_wrapper.lua" ) end -CreateConVar( "gluatest_use_ansi", 1, FCVAR_ARCHIVE, "Should GLuaTest use ANSI coloring in its output", 0, 1 ) +CreateConVar( "gluatest_use_ansi", "1", FCVAR_ARCHIVE, "Should GLuaTest use ANSI coloring in its output", 0, 1 ) -GLuaTest.loader = include( "gluatest/loader.lua" ) -GLuaTest.runner = include( "gluatest/runner/runner.lua" ) -local shouldRun = CreateConVar( "gluatest_enable", 0, FCVAR_ARCHIVE + FCVAR_PROTECTED ) +local shouldRun = CreateConVar( "gluatest_enable", "0", FCVAR_ARCHIVE + FCVAR_PROTECTED ) +--- Loads all GLuaTest-compatible projects from a given path +--- @param path string The path to load projects from (in the LUA mount point) +--- @param testFiles GLuaTest_TestGroup[] The table to add the loaded test files to local function loadAllProjectsFrom( path, testFiles ) + --- @type GLuaTest_Loader + local loader = include( "gluatest/loader.lua" ) + local _, projects = file.Find( path .. "/*", "LUA" ) for i = 1, #projects do local project = projects[i] - table.Add( testFiles, GLuaTest.loader( path .. "/" .. project ) ) + table.Add( testFiles, loader.getTestsInDir( path .. "/" .. project ) ) end end +--- Attempts the read the version of GLuaTest +--- First checks data_static/gluatest_version.txt (when running in docker) +--- Then, attempts to read the git commit of the cloned GLuaTest repository +--- Then, gives up +local function getGLuaTestVersion() +end + +--- Loads and runs all tests in the tests/ directory GLuaTest.runAllTests = function() - if not shouldRun:GetBool() then return end + if not shouldRun:GetBool() then + print( "[GLuaTest] Test runs are disabled. Enable them with: gluatest_enable 1" ) + return + end local testPaths = { "tests", @@ -53,7 +69,9 @@ GLuaTest.runAllTests = function() } hook.Run( "GLuaTest_AddTestPaths", testPaths ) + --- @type GLuaTest_TestGroup[] local testFiles = {} + for i = 1, #testPaths do local path = testPaths[i] loadAllProjectsFrom( path, testFiles ) @@ -61,7 +79,9 @@ GLuaTest.runAllTests = function() hook.Run( "GLuaTest_RunTestFiles", testFiles ) - GLuaTest.runner( testFiles ) + --- @type GLuaTest_TestRunner + local runner = include( "gluatest/runner/runner.lua" ) + runner:Run( testFiles ) end hook.Add( "Tick", "GLuaTest_Runner", function() diff --git a/lua/gluatest/loader.lua b/lua/gluatest/loader.lua index a317f8c..44c8608 100644 --- a/lua/gluatest/loader.lua +++ b/lua/gluatest/loader.lua @@ -1,9 +1,11 @@ -local istable = istable -local runClientside = GLuaTest.RUN_CLIENTSIDE -local noop = function() end +--- @class GLuaTest_Loader +local Loader = {} -local checkSendToClients = function( filePath, cases ) - if not runClientside then return end +--- If the file has clientside cases, send it to the client +--- @param filePath string +--- @param cases GLuaTest_TestCase[] +function Loader.checkSendToClients( filePath, cases ) + if not GLuaTest.RUN_CLIENTSIDE then return end for _, case in ipairs( cases ) do if case.clientside then @@ -12,48 +14,61 @@ local checkSendToClients = function( filePath, cases ) end end --- TODO: How to prevent this from matching: `customtests/blah/blah.lua`? -local getProjectName = function( dir ) - return string.match( dir, "tests/(.+)/.*$" ) +--- Given a full path to a test file or directory, return the project name (the folder under tests/ it exists within) +--- @param dir string The full path to the test file or directory +--- @return string +function Loader.getProjectName( dir ) + return string.match( dir, "tests/(.*)/.*$" ) end -local function processFile( dir, fileName, tests ) +--- Given a directory and a file name, try to load the file as a TestGroup and build a RunnableTestGroup from it +--- @param dir string The directory the file is in +--- @param fileName string The name of the file +--- @param groups GLuaTest_RunnableTestGroup[] +function Loader.processFile( dir, fileName, groups ) if not string.EndsWith( fileName, ".lua" ) then return end local filePath = dir .. "/" .. fileName local fileOutput = include( filePath ) - if not istable( fileOutput ) then return end - if not fileOutput.cases then return end - - if SERVER then checkSendToClients( filePath, fileOutput.cases ) end - - table.insert( tests, { - fileName = fileName, - groupName = fileOutput.groupName, - cases = fileOutput.cases, - project = getProjectName( filePath ), - beforeAll = fileOutput.beforeAll or noop, - beforeEach = fileOutput.beforeEach or noop, - afterAll = fileOutput.afterAll or noop, - afterEach = fileOutput.afterEach or noop - } ) + if not istable( fileOutput ) then + print( "GLuaTest: File " .. filePath .. " did not return a table - ignoring" ) + return + end + if not fileOutput.cases then + print( "GLuaTest: File " .. filePath .. " did not have a 'cases' field - ignoring" ) + return + end + + local testGroup = fileOutput --[[@as GLuaTest_TestGroup]] + + if SERVER then Loader.checkSendToClients( filePath, testGroup.cases ) end + + local group = testGroup + group.fileName = fileName + group.project = Loader.getProjectName( filePath ) + + table.insert( groups, group --[[@as GLuaTest_RunnableTestGroup]] ) end -local function getTestsInDir( dir, tests ) + +--- Given a directory, recursively search for test files and load them into the given tests table +--- @param dir string The directory to search in +--- @param tests? GLuaTest_RunnableTestGroup[] +function Loader.getTestsInDir( dir, tests ) if not tests then tests = {} end local files, dirs = file.Find( dir .. "/*", "LUA" ) for _, fileName in ipairs( files ) do - processFile( dir, fileName, tests ) + Loader.processFile( dir, fileName, tests ) end for _, dirName in ipairs( dirs ) do local newDir = dir .. "/" .. dirName - getTestsInDir( newDir, tests ) + Loader.getTestsInDir( newDir, tests ) end return tests end -return getTestsInDir +return Loader diff --git a/lua/gluatest/runner/colors.lua b/lua/gluatest/runner/colors.lua index 1b03741..6592758 100644 --- a/lua/gluatest/runner/colors.lua +++ b/lua/gluatest/runner/colors.lua @@ -1,3 +1,6 @@ +--- @alias GLuaTest_LogColors table + +--- @type GLuaTest_LogColors local colors = { red = Color( 255, 0, 0 ), green = Color( 0, 255, 0 ), diff --git a/lua/gluatest/runner/helpers.lua b/lua/gluatest/runner/helpers.lua index 9b0c832..0600702 100644 --- a/lua/gluatest/runner/helpers.lua +++ b/lua/gluatest/runner/helpers.lua @@ -1,13 +1,31 @@ -local Helpers = {} +--- @class GLuaTest_RunnerHelpers +local Helpers = { + caseId = 0, + caseIdPrefix = "case_" .. os.time() .. "_", +} + +--- @type GLuaTest_Expect local expect = include( "gluatest/expectations/expect.lua" ) + +--- @type GLuaTest_StubMaker local stubMaker = include( "gluatest/stubs/stubMaker.lua" ) +--- Gets a unique case ID +--- @return string +function Helpers.GetCaseID() + Helpers.caseId = Helpers.caseId + 1 + return Helpers.caseIdPrefix .. Helpers.caseId +end + ------------------ -- Cleanup stuff-- ------------------ -local makeHookTable = function() +--- Makes a mocked hook library that will clean itself up after the test completes +function Helpers.makeHookTable() local trackedHooks = {} + + --- Wrapper over hook.Add that tracks the hooks added local hook_Add = function( event, name, func, ... ) if not trackedHooks[event] then trackedHooks[event] = {} end table.insert( trackedHooks[event], name ) @@ -19,9 +37,12 @@ local makeHookTable = function() end end + + ---@diagnostic disable-next-line: redundant-parameter return _G.hook.Add( event, name, func, ... ) end + --- Cleans up all the hooks that were added local function cleanup() for event, names in pairs( trackedHooks ) do for _, name in ipairs( names ) do @@ -48,12 +69,13 @@ local makeHookTable = function() end local timerCount = 0 -local function makeTimerTable() +function Helpers.makeTimerTable() local timerNames = {} local timer_Create = function( identifier, delay, reps, func, ... ) table.insert( timerNames, identifier ) + ---@diagnostic disable-next-line: redundant-parameter return timer.Create( identifier, delay, reps, func, ... ) end @@ -73,9 +95,9 @@ local function makeTimerTable() return table.Inherit( { Create = timer_Create, Simple = timer_Simple }, timer ), cleanup end -local function makeTestLibStubs() - local testHook, hookCleanup = makeHookTable() - local testTimer, timerCleanup = makeTimerTable() +function Helpers.makeTestLibStubs() + local testHook, hookCleanup = Helpers.makeHookTable() + local testTimer, timerCleanup = Helpers.makeTimerTable() local testEnv = { hook = testHook, @@ -90,9 +112,13 @@ local function makeTestLibStubs() return testEnv, cleanup end -local function makeTestTools() +--- Creates a new set of test tools (stubs, expectations, etc) +--- @return GLuaTest_TestTools tools The test tools +--- @return fun(): nil cleanup The cleanup function +function Helpers.makeTestTools() local stub, stubCleanup = stubMaker() + --- @class GLuaTest_TestTools local tools = { stub = stub, expect = expect, @@ -105,9 +131,12 @@ local function makeTestTools() return tools, cleanup end -local function makeTestEnv() - local testEnv, envCleanup = makeTestLibStubs() - local testTools, toolsCleanup = makeTestTools() +--- Creates a new environment for a test to run in +--- @return table testEnv The test environment +--- @return fun(): nil cleanup The cleanup function +function Helpers.makeTestEnv() + local testEnv, envCleanup = Helpers.makeTestLibStubs() + local testTools, toolsCleanup = Helpers.makeTestTools() local function cleanup() envCleanup() @@ -128,7 +157,15 @@ local function makeTestEnv() return env, cleanup end -local function getLocals( level ) +--- @class GLuaTest_LocalVariable +--- @field name string +--- @field value string + +--- Returns all locals from a given stack level +--- @param level number +--- @return GLuaTest_LocalVariable[] +function Helpers.getLocals( level ) + --- @type GLuaTest_LocalVariable[] local locals = {} local i = 1 @@ -144,8 +181,10 @@ local function getLocals( level ) return locals end --- FIXME: There has to be a better way to do this -local function findStackInfo() +--- Navigates the stack to find the correct stack level and info to report error information +--- @return number The stack level +--- @return debuginfo The stack info +function Helpers.findStackInfo() -- Step up through the stacks to find the error we care about for stack = 1, 12 do @@ -165,10 +204,18 @@ local function findStackInfo() return 2, debug.getinfo( 2, "lnS" ) end +--- @class GLuaTest_FailCallbackInfo +--- @field reason string The error message +--- @field sourceFile? string The file the error occurred in +--- @field lineNumber? number The line number the error occurred on +--- @field locals? GLuaTest_LocalVariable[] The local variables at the time of the error + +--- A callback for when a test fails in xpcall +--- @param reason string +--- @return GLuaTest_FailCallbackInfo function Helpers.FailCallback( reason ) if reason == "" then - ErrorNoHaltWithStack( "Received empty error reason in failCallback- ignoring " ) - return + error( "Received empty error reason in failCallback- ignoring " ) end -- root/file/name.lua:420: Expectation Failed: Failure reason @@ -183,8 +230,8 @@ function Helpers.FailCallback( reason ) local cleanReason = table.concat( reasonSpl, ": ", 2, #reasonSpl ) - local level, info = findStackInfo() - local locals = getLocals( level ) + local level, info = Helpers.findStackInfo() + local locals = Helpers.getLocals( level ) return { reason = cleanReason, @@ -194,43 +241,53 @@ function Helpers.FailCallback( reason ) } end +--- Creates a new environment for a test to run in +--- @param done fun(): nil The function called by the test to signal completion +--- @param fail fun( reason: string ): nil The function called by the test to signal failure +--- @param onFailedExpectation fun( errInfo: GLuaTest_FailCallbackInfo ): nil The function called when an expectation fails function Helpers.MakeAsyncEnv( done, fail, onFailedExpectation ) -- TODO: How can we make Stubs safer in Async environments? local stub, stubCleanup = stubMaker() - local testEnv, envCleanup = makeTestLibStubs() + local testEnv, envCleanup = Helpers.makeTestLibStubs() + --- Function that cleans up all actions taken by the test local function cleanup() envCleanup() stubCleanup() end - local env = setmetatable( - { - -- We manually catch expectation errors here in case - -- they're called in an async function - expect = function( subject ) - local built = expect( subject ) - local expected = built.to.expected - local recordedFailure = false - - -- Wrap the error-throwing function - -- and handle the error with the correct context - built.to.expected = function( ... ) - if recordedFailure then return end - - local _, errInfo = xpcall( expected, Helpers.FailCallback, ... ) - onFailedExpectation( errInfo ) + --- @class GLuaTest_AsyncEnv + local asyncEnv = { + -- We manually catch expectation errors here in case + -- they're called in an async function + expect = function( subject, ... ) + local built = expect( subject, ... ) + local expected = built.to.expected + local recordedFailure = false + + -- Wrap the error-throwing function + -- and handle the error with the correct context + -- (and to only record the first failure) + built.to.expected = function( ... ) + if recordedFailure then return end + + local _, errInfo = xpcall( expected, Helpers.FailCallback, ... ) + onFailedExpectation( errInfo --[[@as GLuaTest_FailCallbackInfo]] ) + + recordedFailure = true + print( "Expectation failed: will not run again" ) + end - recordedFailure = true - end + return built + end, - return built - end, + done = done, + fail = fail, + stub = stub, + } - done = done, - fail = fail, - stub = stub, - }, + local env = setmetatable( + asyncEnv, { __index = function( _, idx ) return testEnv[idx] or _G[idx] @@ -243,8 +300,14 @@ function Helpers.MakeAsyncEnv( done, fail, onFailedExpectation ) return env, cleanup end +--- Runs a function with a given environment, and cleans up after +--- @param defaultEnv table +--- @param before? fun( state: GLuaTest_TestState ): nil The function to run before the test +--- @param func fun( state: GLuaTest_TestState ): nil The test function to run +--- @param state GLuaTest_TestState The state to pass to the test +--- @return GLuaTest_CaseRunResult The result of the test function Helpers.SafeRunWithEnv( defaultEnv, before, func, state ) - local testEnv, cleanup = makeTestEnv() + local testEnv, cleanup = Helpers.makeTestEnv() local ranExpect = false local ogExpect = testEnv.expect @@ -254,24 +317,39 @@ function Helpers.SafeRunWithEnv( defaultEnv, before, func, state ) return ogExpect( ... ) end - setfenv( before, testEnv ) - before( state ) - setfenv( before, defaultEnv ) + if before then + setfenv( before, testEnv ) + before( state ) + setfenv( before, defaultEnv ) + end setfenv( func, testEnv ) - local success, errInfo = xpcall( func, Helpers.FailCallback, state ) + local success, output = xpcall( func, Helpers.FailCallback, state ) setfenv( func, defaultEnv ) cleanup() - -- If it succeeded but never ran `expect`, it's an empty test - if success and not ranExpect then - return nil, nil + if success then + -- If it succeeded but never ran `expect`, it's an empty test + if not ranExpect then + local empty = { result = "empty" } --[[@as GLuaTest_CaseEmpty]] + return empty + end + + local successful = { result = "success" } --[[@as GLuaTest_CaseSuccess]] + return successful end - return success, errInfo + -- Test failure + local errInfo = output --[[@as GLuaTest_FailCallbackInfo]] + local failure = { result = "failure", errInfo = errInfo } --[[@as GLuaTest_CaseFailure]] + + return failure end +--- Creates a new test state +--- The state is unique to each case, but has a group-level passthrough +--- @return GLuaTest_TestState function Helpers.CreateCaseState( testGroupState ) return setmetatable( {}, { __index = function( self, idx ) diff --git a/lua/gluatest/runner/log_helpers.lua b/lua/gluatest/runner/log_helpers.lua index 1fa6829..dc1f314 100644 --- a/lua/gluatest/runner/log_helpers.lua +++ b/lua/gluatest/runner/log_helpers.lua @@ -1,8 +1,12 @@ local string_Explode = string.Explode local table_concat = table.concat +--- @class GLuaTest_LogHelpers local LogHelpers = {} +--- Given a line of code, returns the leading whitespace +--- @param line string +--- @return string function LogHelpers.GetLeadingWhitespace( line ) return string.match( line, "^%s+" ) or "" end @@ -21,7 +25,7 @@ function LogHelpers.cleanPathForRead( path ) if step == "lua" or step == "gamemodes" then startCopy = i + 1 - assert( startCopy < #expl ) + assert( startCopy <= #expl, "Unhandled path! Please report this" ) break end end @@ -29,41 +33,59 @@ function LogHelpers.cleanPathForRead( path ) return table_concat( expl, "/", startCopy, #expl ) end -LogHelpers.fileCache = {} +--- @class GLuatest_LogHelpers_FileLinesCache +LogHelpers.fileLinesCache = { + --- @type table + cache = {}, + + --- Caches the file lines for a given file path + --- @param filePath string + --- @param fileLines string[] + set = function( self, filePath, fileLines ) + self.cache[filePath] = fileLines + end, + + --- Returns the cached file lines for a given file path + --- @param filePath string + --- @return string[]? + get = function( self, filePath ) + return self.cache[filePath] + end, + + --- Clears the file lines cache + clear = function( self ) + self.cache = {} + end +} --- Reads a given file path and returns the contents split by newline --- Cached for future calls --- @param filePath string --- @return string[] function LogHelpers.getFileLines( filePath ) - -- - -- Reads a given file path and returns the contents split by newline. - -- Caches the output for future calls. - -- - local cached = LogHelpers.fileCache[filePath] + local cached = LogHelpers.fileLinesCache:get( filePath ) if cached then return cached end local cleanPath = LogHelpers.cleanPathForRead( filePath ) - local testFile = file.Open( cleanPath, "r", "LUA" ) + local testFile = file.Open( cleanPath, "r", "LUA" ) --[[@as File]] local fileContents = testFile:Read( testFile:Size() ) testFile:Close() local fileLines = string.Split( fileContents, "\n" ) - LogHelpers.fileCache[filePath] = fileLines + LogHelpers.fileLinesCache:set( filePath, fileLines ) return fileLines end hook.Add( "GLuaTest_Finished", "GLuaTest_FileCacheCleanup", function() - LogHelpers.fileCache = {} + LogHelpers.fileLinesCache:clear() end ) +--- Given a table of code lines, return a string +--- containing the leading spacing can be removed +--- without losing any context +--- @param lines string[] +--- @return number The number of characters that are safe to remove function LogHelpers.getLeastSharedIndent( lines ) - -- - -- Given a table of code lines, return a string - -- containing the leading spacing can be removed - -- without losing any context - -- - local leastShared = math.huge for _, lineContent in ipairs( lines ) do @@ -76,22 +98,31 @@ function LogHelpers.getLeastSharedIndent( lines ) end end + if leastShared == math.huge then return 0 end return leastShared or 0 end +--- Given lines of code, dedent them by the least shared indent +--- (i.e. dedent the code as much as possible without losing meaningful indentation) +--- @param lines string[] The lines of code to dedent +--- @return string[] The dedented lines function LogHelpers.NormalizeLinesIndent( lines ) local leastSharedIndent = LogHelpers.getLeastSharedIndent( lines ) if leastSharedIndent == 0 then return lines end for i = 1, #lines do - local lineContent = lines[i] - lines[i] = string.Right( lineContent, #lineContent - leastSharedIndent ) + local line = lines[i] + lines[i] = string.sub( line, leastSharedIndent + 1 ) end return lines end - +--- Return the desired line of code with a configurable amount of context above/below +--- @param path string The path to the file +--- @param line number The line number to get +--- @param context? number The number of lines above and below to include (Default: 5) +--- @return string[] The lines of code with context function LogHelpers.GetLineWithContext( path, line, context ) if not context then context = 5 end local fileLines = LogHelpers.getFileLines( path ) @@ -105,12 +136,12 @@ function LogHelpers.GetLineWithContext( path, line, context ) return lineWithContext end - +--- Generates an appropriately-sized divider line +--- based on the longest line and the length of the failure reason +--- @param lines string[] The lines of code +--- @param reason string The reason for the failure +--- @return string The divider line function LogHelpers.GenerateDivider( lines, reason ) - -- - -- Generates an appropriately-sized divider line - -- based on the longest line and the length of the failure reason - -- local longestLine = 0 for i = 1, #lines do @@ -125,8 +156,5 @@ function LogHelpers.GenerateDivider( lines, reason ) return string.rep( "_", dividerLength ) end - - hook.Run( "GLuaTest_MakeLogHelpers", LogHelpers ) - return LogHelpers diff --git a/lua/gluatest/runner/logger.lua b/lua/gluatest/runner/logger.lua index 793d6ec..084b3a7 100644 --- a/lua/gluatest/runner/logger.lua +++ b/lua/gluatest/runner/logger.lua @@ -1,12 +1,15 @@ +--- @type GLuaTest_LogHelpers local Helpers = include( "gluatest/runner/log_helpers.lua" ) local GenerateDivider = Helpers.GenerateDivider local GetLineWithContext = Helpers.GetLineWithContext local GetLeadingWhitespace = Helpers.GetLeadingWhitespace local NormalizeLinesIndent = Helpers.NormalizeLinesIndent +--- @type GLuaTest_LogColors local colors = include( "gluatest/runner/colors.lua" ) local MsgC = include( "gluatest/runner/msgc_wrapper.lua" ) +--- @class GLuaTest_ResultLogger local ResultLogger = {} @@ -18,20 +21,22 @@ function ResultLogger.prefixLog( ... ) end +--- Draws a line of code in the Context Block +--- @param content string +--- @param lineNumber string function ResultLogger.drawLine( content, lineNumber ) - -- - -- Draws a line of code in the Context Block - -- MsgC( colors.grey, string.rep( " ", 4 - #lineNumber ) ) MsgC( colors.white, lineNumber, " " ) MsgC( colors.grey, "| ", content ) end +--- Draw a given line of code, a pointer-arrow, and the failure reason +--- @param content string +--- @param lineNumber string +--- @param divider string +--- @param reason string function ResultLogger.drawFailingLine( content, lineNumber, divider, reason ) - -- - -- Draw a given line of code, a pointer-arrow, and the failure reason - -- local contentLength = 7 + #content local newLineLength = contentLength + 2 + #reason @@ -62,11 +67,10 @@ function ResultLogger.drawFailingLine( content, lineNumber, divider, reason ) end +--- Given a test failure, gather info about the failing code +--- and draw a block of code context with a pointer-arrow to the failure +--- @param errInfo GLuaTest_FailCallbackInfo function ResultLogger.logCodeContext( errInfo ) - -- - -- Given a test failure, gather info about the failing code - -- and draw a block of code context with a pointer-arrow to the failure - -- local reason = errInfo.reason local sourceFile = errInfo.sourceFile local lineNumber = errInfo.lineNumber @@ -80,19 +84,19 @@ function ResultLogger.logCodeContext( errInfo ) return end - local lines = GetLineWithContext( sourceFile, lineNumber ) + local lines = GetLineWithContext( assert( sourceFile ), assert( lineNumber ) ) local lineCount = #lines lines = NormalizeLinesIndent( lines ) local divider = GenerateDivider( lines, reason ) MsgC( colors.white, " Context:", "\n" ) - MsgC( colors.grey, " ", divider, "\n" ) - MsgC( colors.grey, " | ", "\n" ) + MsgC( colors.grey, " ", divider, "\n" ) + MsgC( colors.grey, " | ", "\n" ) for i = 1, lineCount do local lineContent = lines[i] - local contextLineNumber = lineNumber - ( lineCount - i ) + local contextLineNumber = lineNumber - (lineCount - i) local lineNumStr = tostring( contextLineNumber ) local onFailingLine = contextLineNumber == lineNumber @@ -104,16 +108,15 @@ function ResultLogger.logCodeContext( errInfo ) end end - MsgC( colors.grey, " |", divider, "\n" ) + MsgC( colors.grey, " |", divider, "\n" ) end +--- Given a test failure with local variables, +--- draw a section to display the name and values +--- of up to 5 local variables in the failing test +--- @param errInfo GLuaTest_FailCallbackInfo function ResultLogger.logLocals( errInfo ) - -- - -- Given a test failure with local variables, - -- draw a section to display the name and values - -- of up to 5 local variables in the failing test - -- local locals = errInfo.locals or {} local localCount = math.min( 5, #locals ) @@ -134,20 +137,21 @@ function ResultLogger.logLocals( errInfo ) end +--- Draw information about a given test failure +--- @param errInfo GLuaTest_FailCallbackInfo function ResultLogger.logTestCaseFailure( errInfo ) - -- - -- Draw information about a given test failure - -- local sourceFile = errInfo.sourceFile MsgC( colors.white, " File:", "\n" ) - MsgC( colors.grey, " ", sourceFile, "\n\n" ) + MsgC( colors.grey, " ", sourceFile, "\n\n" ) ResultLogger.logLocals( errInfo ) ResultLogger.logCodeContext( errInfo ) end +--- Given a list of test results, return the counts of each type +--- @param allResults GLuaTest_TestResult[] function ResultLogger.getResultCounts( allResults ) local passed = 0 local failed = 0 @@ -170,6 +174,9 @@ function ResultLogger.getResultCounts( allResults ) end +--- Given a list of test results, return a table of failures grouped by test group +--- @param allResults GLuaTest_TestResult[] +--- @return table function ResultLogger.getFailuresByGroup( allResults ) local failuresByGroup = {} @@ -186,18 +193,23 @@ function ResultLogger.getFailuresByGroup( allResults ) end +--- Log the start of a test group +--- @param testGroup GLuaTest_RunnableTestGroup function ResultLogger.LogFileStart( testGroup ) local fileName = testGroup.fileName local groupName = testGroup.groupName local project = testGroup.project - local identifier = project .. "/" .. ( groupName or fileName ) + local identifier = project .. "/" .. (groupName or fileName) MsgC( "\n" ) ResultLogger.prefixLog( colors.blue, "=== Running ", identifier, "... ===", "\n" ) end +--- Log the result of a test +--- @param result GLuaTest_TestResult +--- @param usePrefix? boolean (default true) function ResultLogger.LogTestResult( result, usePrefix ) if usePrefix == nil then usePrefix = true end @@ -227,9 +239,11 @@ function ResultLogger.LogTestResult( result, usePrefix ) end +--- Log the details of a test failure +--- @param failure GLuaTest_TestResult function ResultLogger.LogTestFailureDetails( failure ) local case = failure.case - local errInfo = failure.errInfo + local errInfo = failure.errInfo or {} -- If the error came through without a source line, -- we'll use the function definition @@ -246,6 +260,10 @@ function ResultLogger.LogTestFailureDetails( failure ) end +--- Prints the summary of a test run +--- @param testGroups GLuaTest_TestGroup[] +--- @param allResults GLuaTest_TestResult[] +--- @param duration number function ResultLogger.logSummaryIntro( testGroups, allResults, duration ) local niceDuration = string.format( "%.3f", duration ) local white = colors.white @@ -265,6 +283,8 @@ function ResultLogger.logSummaryIntro( testGroups, allResults, duration ) end +--- Log the counts of each type of test result +--- @param allResults GLuaTest_TestResult[] function ResultLogger.logSummaryCounts( allResults ) local white = colors.white local blue = colors.blue @@ -273,13 +293,15 @@ function ResultLogger.logSummaryCounts( allResults ) local darkgrey = colors.darkgrey local passed, failed, empty, skipped = ResultLogger.getResultCounts( allResults ) - ResultLogger.prefixLog( white, "| ", green, "PASS: ", blue, passed, "\n" ) - ResultLogger.prefixLog( white, "| ", red, "FAIL: ", blue, failed, "\n" ) - ResultLogger.prefixLog( white, "| ", darkgrey, "EMPT: ", blue, empty, "\n" ) - ResultLogger.prefixLog( white, "| ", darkgrey, "SKIP: ", blue, skipped, "\n" ) + ResultLogger.prefixLog( white, "| ", green, "PASS: ", blue, passed, "\n" ) + ResultLogger.prefixLog( white, "| ", red, "FAIL: ", blue, failed, "\n" ) + ResultLogger.prefixLog( white, "| ", darkgrey, "EMPT: ", blue, empty, "\n" ) + ResultLogger.prefixLog( white, "| ", darkgrey, "SKIP: ", blue, skipped, "\n" ) end +--- Log a summary of all test failures +--- @param allResults GLuaTest_TestResult[] function ResultLogger.logFailureSummary( allResults ) local allFailures = ResultLogger.getFailuresByGroup( allResults ) if table.Count( allFailures ) == 0 then return end @@ -292,7 +314,7 @@ function ResultLogger.logFailureSummary( allResults ) local groupName = group.groupName local project = group.project - local identifier = project .. "/" .. ( groupName or fileName ) + local identifier = project .. "/" .. (groupName or fileName) MsgC( colors.blue, "=== ", identifier, " ===", "\n" ) for _, failure in ipairs( failures ) do @@ -304,6 +326,10 @@ function ResultLogger.logFailureSummary( allResults ) end +--- Log the final result of a test run +--- @param testGroups GLuaTest_TestGroup[] +--- @param allResults GLuaTest_TestResult[] +--- @param duration number function ResultLogger.LogTestsComplete( testGroups, allResults, duration ) MsgC( "\n", "\n" ) ResultLogger.logSummaryIntro( testGroups, allResults, duration ) @@ -313,16 +339,15 @@ function ResultLogger.LogTestsComplete( testGroups, allResults, duration ) ResultLogger.PlainLogEnd() end --- External parsers rely on the output of this function it should not be changed often +--- External parsers rely on the output of this function, it should not be changed often function ResultLogger.PlainLogStart() print( "[GLuaTest]: Test run starting..." ) end --- External parsers rely on the output of this function it should not be changed often +--- External parsers rely on the output of this function, it should not be changed often function ResultLogger.PlainLogEnd() print( "[GLuaTest]: Test run complete!" ) end hook.Run( "GLuaTest_MakeResultLogger", ResultLogger ) - return ResultLogger diff --git a/lua/gluatest/runner/msgc_wrapper.lua b/lua/gluatest/runner/msgc_wrapper.lua index 9feca0c..760accb 100644 --- a/lua/gluatest/runner/msgc_wrapper.lua +++ b/lua/gluatest/runner/msgc_wrapper.lua @@ -9,9 +9,15 @@ if SERVER then _G["_MsgC"] = _G["MsgC"] local _MsgC = _G["_MsgC"] + --- @diagnostic disable-next-line: err-esc local startColor = "\x1b[38;2;" + + --- @diagnostic disable-next-line: err-esc local endColor = "\x1b[0m" + --- Converts the given Color object to an ANSI string + --- @param col Color + --- @return string local function colorToAnsi( col ) local r = col.r local g = col.g diff --git a/lua/gluatest/runner/runner.lua b/lua/gluatest/runner/runner.lua index fec39a9..3bade1a 100644 --- a/lua/gluatest/runner/runner.lua +++ b/lua/gluatest/runner/runner.lua @@ -1,275 +1,72 @@ -local Helpers = include( "gluatest/runner/helpers.lua" ) -local FailCallback = Helpers.FailCallback -local MakeAsyncEnv = Helpers.MakeAsyncEnv -local SafeRunWithEnv = Helpers.SafeRunWithEnv -local CreateCaseState = Helpers.CreateCaseState +include( "gluatest/runner/test_group_runner.lua" ) +--- @type GLuaTest_ResultLogger local ResultLogger = include( "gluatest/runner/logger.lua" ) local LogFileStart = ResultLogger.LogFileStart local LogTestResult = ResultLogger.LogTestResult local LogTestsComplete = ResultLogger.LogTestsComplete local LogTestFailureDetails = ResultLogger.LogTestFailureDetails local PlainLogStart = ResultLogger.PlainLogStart -local noop = function() end -GLuaTest_CaseID = GLuaTest_CaseID or 0 -local function getCaseID() - GLuaTest_CaseID = GLuaTest_CaseID + 1 - return "case" .. GLuaTest_CaseID -end - -return function( allTestGroups ) - if CLIENT and not GLuaTest.RUN_CLIENTSIDE then return end - - -- A copy of the original test groups for later reference - local originalTestGroups = table.Copy( allTestGroups ) +--- @class GLuaTest_TestRunner +local TestRunner = {} - -- Sequential table of Result structures - local allResults = {} +--- @type GLuaTest_TestResult[] +TestRunner.results = {} - local function addResult( result ) - hook.Run( "GLuaTest_LogTestResult", result ) +--- Adds and logs a result to the test run +--- @param result GLuaTest_TestResult +function TestRunner:AddResult( result ) + hook.Run( "GLuaTest_LogTestResult", result ) + table.insert( self.results, result ) - table.insert( allResults, result ) - - LogTestResult( result ) - if result.success == false then LogTestFailureDetails( result ) end - end - - -- case.when should evaluate to `true` if the test should run - -- case.skip should evaluate to `true` if the test should be skipped - -- (case.skip takes precedence over case.when) - local function checkShouldSkip( case ) - -- skip - local skip = case.skip - if skip == true then return true end - if isfunction( skip ) then - return skip() == true - end + LogTestResult( result ) + if result.success == false then LogTestFailureDetails( result ) end +end - -- when - local condition = case.when - if condition == nil then return false end - if condition == false then return true end +--- Completes the test run +--- @param testGroups GLuaTest_TestGroup[] +function TestRunner:Complete( testGroups ) + local duration = SysTime() - self.startTime - if isfunction( condition ) then - return condition() ~= true - end + hook.Run( "GLuaTest_Finished", testGroups, self.results, duration ) + LogTestsComplete( testGroups, self.results, duration ) +end - return condition ~= true - end +--- Runs all given test groups +--- @param testGroups GLuaTest_RunnableTestGroup[] +function TestRunner:Run( testGroups ) + if CLIENT and not GLuaTest.RUN_CLIENTSIDE then return end PlainLogStart() - hook.Run( "GLuaTest_StartedTestRun", allTestGroups ) - local startTime = SysTime() - local defaultEnv = getfenv( 1 ) - - local testGroup - local testGroupState = {} - local function runNextTestGroup( testGroups ) - if testGroup then testGroup.afterAll( testGroupState ) end - - testGroup = table.remove( testGroups ) - testGroupState = {} - - if not testGroup then - local duration = SysTime() - startTime - - hook.Run( "GLuaTest_Finished", originalTestGroups, allResults, duration ) - LogTestsComplete( originalTestGroups, allResults, duration ) - - return - end - - local function setSucceeded( case ) - return addResult( { - case = case, - testGroup = testGroup, - success = true, - } ) - end - - local function setFailed( case, errInfo ) - return addResult( { - case = case, - testGroup = testGroup, - success = false, - errInfo = errInfo - } ) - end - - local function setTimedOut( case ) - return setFailed( case, { reason = "Timeout" } ) - end - - local function setSkipped( case ) - return addResult( { - case = case, - testGroup = testGroup, - skipped = true, - } ) - end - - local function setEmpty( case ) - return addResult( { - case = case, - testGroup = testGroup, - empty = true, - } ) - end - - local cases = testGroup.cases - local caseCount = #cases - LogFileStart( testGroup ) - testGroup.beforeAll( testGroupState ) + hook.Run( "GLuaTest_StartedTestRun", testGroups ) + self.startTime = SysTime() - local asyncCases = {} + --- @type GLuaTest_TestGroupRunner[] + local runners = {} - local function processCase( case ) - local shouldSkip = checkShouldSkip( case ) - if shouldSkip then - setSkipped( case ) - return - end - - -- Returning false from this hook will hide it from the output - local canRun = hook.Run( "GLuaTest_CanRunTestCase", testGroup, case ) - if canRun == nil then canRun = true end - if not canRun then return end - - -- Tests in the wrong realm will be hidden from output - local shared = case.shared - local clientside = case.clientside - local serverside = not case.clientside - local correctRealm = shared or ( clientside and CLIENT ) or ( serverside and SERVER ) - if not correctRealm then return end - - if case.async then - asyncCases[case.id] = case - else - local beforeFunc = testGroup.beforeEach - local success, errInfo = SafeRunWithEnv( defaultEnv, beforeFunc, case.func, case.state ) - - case.cleanup( case.state ) - testGroup.afterEach( case.state ) - - if success then - setSucceeded( case ) - elseif success == nil then - setEmpty( case ) - else - setFailed( case, errInfo ) - end - end - end - - for c = 1, caseCount do - local case = cases[c] - case.id = getCaseID() - case.state = case.state or CreateCaseState( testGroupState ) - case.cleanup = case.cleanup or noop - - processCase( case ) - end - - local asyncCount = table.Count( asyncCases ) - if asyncCount == 0 then - runNextTestGroup( testGroups ) - return - end + for _, group in ipairs( testGroups ) do + local runner = GLuaTest.TestGroupRunner( self, group ) + table.insert( runners, runner ) + end - local callbacks = {} - local checkComplete = function() - local cbCount = table.Count( callbacks ) - if cbCount ~= asyncCount then return end + local function runNext() + --- @type GLuaTest_TestGroupRunner + local nextRunner = table.remove( runners ) - runNextTestGroup( testGroups ) + if not nextRunner then + return self:Complete( testGroups ) end - for _, case in pairs( asyncCases ) do - local expectationFailure = false - local asyncCleanup = function() - ErrorNoHaltWithStack( "Running an empty Async Cleanup func" ) - end - - case.testComplete = function() - timer.Remove( "GLuaTest_AsyncTimeout_" .. case.id ) - setfenv( case.func, defaultEnv ) - - case.cleanup( case.state ) - testGroup.afterEach( case.state ) - - asyncCleanup() - checkComplete() - end + LogFileStart( nextRunner.group ) - local done = function() - if callbacks[case.id] ~= nil then return end - - if not expectationFailure then - setSucceeded( case ) - end - - callbacks[case.id] = not expectationFailure - case.testComplete() - end - - local fail = function( reason ) - if callbacks[case.id] ~= nil then return end - - setFailed( case, { reason = reason or "fail() called" } ) - callbacks[case.id] = false - case.testComplete() - end - - -- Received an expectation failure - -- We will record it here, but still expect them - -- to call done(). - -- - -- This will only be called once, even though many - -- expectations may fail. - local onFailedExpectation = function( errInfo ) - if callbacks[case.id] ~= nil then return end - - setFailed( case, errInfo ) - expectationFailure = true - end - - local asyncEnv, asyncCleanupFunc = MakeAsyncEnv( done, fail, onFailedExpectation ) - asyncCleanup = asyncCleanupFunc - - setfenv( testGroup.beforeEach, asyncEnv ) - testGroup.beforeEach( case.state ) - setfenv( testGroup.beforeEach, defaultEnv ) - - setfenv( case.func, asyncEnv ) - local success, errInfo = xpcall( case.func, FailCallback, case.state ) - - -- If the test failed while calling it - -- (Async expectation failures handled in asyncEnv.expect) - -- (Async unhandled failures handled with timeouts) - if not success then - setFailed( case, errInfo ) - callbacks[case.id] = false - case.testComplete() - else - -- If the test ran successfully, start the case-specific timeout timer - - if callbacks[case.id] == nil then - -- If the async case actually operated synchronously (i.e. called done() or fail() before we got here) then we don't need to set a timeout - local timeout = case.timeout or 60 - - timer.Create( "GLuaTest_AsyncTimeout_" .. case.id, timeout, 1, function() - setTimedOut( case ) - callbacks[case.id] = false - - case.testComplete() - end ) - end - end - end + timer.Simple( 0, function() + nextRunner:Run( runNext ) + end ) end - runNextTestGroup( allTestGroups ) + runNext() end + +return TestRunner diff --git a/lua/gluatest/runner/test_case_runner.lua b/lua/gluatest/runner/test_case_runner.lua new file mode 100644 index 0000000..10521bf --- /dev/null +++ b/lua/gluatest/runner/test_case_runner.lua @@ -0,0 +1,203 @@ +local isfunction = isfunction + +--- @type GLuaTest_RunnerHelpers +local Helpers = include( "gluatest/runner/helpers.lua" ) + +--- case.when should evaluate to `true` if the test should run +--- case.skip should evaluate to `true` if the test should be skipped +--- (case.skip takes precedence over case.when) +--- @param case GLuaTest_RunnableTestCase +local function checkShouldSkip( case ) + -- skip + local skip = case.skip + if skip == true then return true end + if skip and isfunction( skip ) then + return skip() == true + end + + -- when + local condition = case.when + if condition == nil then return false end + if condition == false then return true end + + if isfunction( condition ) then + return condition() ~= true + end + + return condition ~= true +end + +--- @param TestGroupRunner GLuaTest_TestGroupRunner +--- @param case GLuaTest_RunnableTestCase +--- @return GLuaTest_TestCaseRunner +function GLuaTest.TestCaseRunner( TestGroupRunner, case ) + local group = assert( TestGroupRunner.group ) --[[@as GLuaTest_TestGroup]] + + local defaultEnv = getfenv( 1 ) + + --- @class GLuaTest_TestCaseRunner + local TCR = {} + + --- Checks if the given test case can/should be run + function TCR:CanRun() + local shouldSkip = checkShouldSkip( case ) + if shouldSkip then + TestGroupRunner:SetSkipped( case ) + return false + end + + local canRun = hook.Run( "GLuaTest_CanRunTestCase", TestGroupRunner.group, case ) + if canRun == nil then canRun = true end + if not canRun then return false end + + -- Tests in the wrong realm will be hidden from output + local shared = case.shared + local clientside = case.clientside + local serverside = not case.clientside + local correctRealm = shared or ( clientside and CLIENT ) or ( serverside and SERVER ) + if not correctRealm then return false end + + return true + end + + --- Run the case synchronously + --- @param cb fun(): nil The function to run once the test is complete + function TCR:RunSync( cb ) + local beforeFunc = group.beforeEach + local caseResult = Helpers.SafeRunWithEnv( defaultEnv, beforeFunc, case.func, case.state ) + + case.cleanup( case.state ) + + local afterEach = group.afterEach + if afterEach then afterEach( case.state ) end + + if caseResult.result == "empty" then + TestGroupRunner:SetEmpty( case ) + elseif caseResult.result == "success" then + TestGroupRunner:SetSucceeded( case ) + elseif caseResult.result == "failure" then + local errInfo = caseResult.errInfo + TestGroupRunner:SetFailed( case, errInfo ) + end + + cb() + end + + --- Run the case asynchronously + --- @param cb fun(): nil The function to run once the test is complete + function TCR:RunAsync( cb ) + local isDone = false + local expectationFailure = false + + local asyncCleanup = function() + ErrorNoHaltWithStack( "Running an empty Async Cleanup func" ) + end + + local function testComplete() + isDone = true + + timer.Remove( "GLuaTest_AsyncTimeout_" .. case.id ) + setfenv( case.func, defaultEnv ) + + local cleanup = case.cleanup + if cleanup then case.cleanup( case.state ) end + + local afterEach = group.afterEach + if afterEach then group.afterEach( case.state ) end + + asyncCleanup() + cb() + end + + --- Call to manually mark the test as done + --- (injected into the test's environment) + local function done() + if isDone then return end + + if not expectationFailure then + TestGroupRunner:SetSucceeded( case ) + end + + testComplete() + end + + --- Call to manually fail the test + --- (injected into the test's environment) + --- @param reason? string + local function fail( reason ) + if isDone then return end + + TestGroupRunner:SetFailed( case, { reason = reason or "fail() called" } ) + testComplete() + end + + --- Received an expectation failure + --- We will record it here, but still expect them + --- to call done(). + --- + --- This will only be called once, even though many + --- expectations may fail. + --- @param errInfo GLuaTest_FailCallbackInfo + local function onFailedExpectation( errInfo ) + if isDone then return end + if expectationFailure then return end + + TestGroupRunner:SetFailed( case, errInfo ) + expectationFailure = true + end + + local asyncEnv, asyncCleanupFunc = Helpers.MakeAsyncEnv( done, fail, onFailedExpectation ) + asyncCleanup = asyncCleanupFunc + + local beforeEach = group.beforeEach + if beforeEach then + setfenv( beforeEach, asyncEnv ) + beforeEach( case.state ) + setfenv( beforeEach, defaultEnv ) + end + + setfenv( case.func, asyncEnv ) + local success, errInfo = xpcall( case.func, Helpers.FailCallback, case.state ) + + -- If the test failed while calling it + -- (Async expectation failures handled in asyncEnv.expect) + -- (Async unhandled failures handled with timeouts) + if not success then + TestGroupRunner:SetFailed( case, errInfo ) + testComplete() + + return + end + + -- If the async case actually operated synchronously + -- (i.e. called done() or fail() before we got here) + -- then we don't need to set a timeout + if isDone then return end + + -- If the test ran successfully, start the case-specific timeout timer + local timeout = case.timeout or 60 + + timer.Create( "GLuaTest_AsyncTimeout_" .. case.id, timeout, 1, function() + TestGroupRunner:SetTimedOut( case ) + testComplete() + end ) + end + + --- Run the test case + --- @param cb fun(): nil The function to run once the test is complete + function TCR:Run( cb ) + if not self:CanRun() then + return cb() + end + + local func = case.async and self.RunAsync or self.RunSync + + if case.coroutine then + func = coroutine.wrap( func ) + end + + func( self, cb ) + end + + return TCR +end diff --git a/lua/gluatest/runner/test_group_runner.lua b/lua/gluatest/runner/test_group_runner.lua new file mode 100644 index 0000000..b6f52e0 --- /dev/null +++ b/lua/gluatest/runner/test_group_runner.lua @@ -0,0 +1,114 @@ +include( "gluatest/runner/test_case_runner.lua" ) + +--- @type GLuaTest_RunnerHelpers +local Helpers = include( "gluatest/runner/helpers.lua" ) +local noop = function() end + +--- Create a new TestGroupRunner +--- @param TestRunner GLuaTest_TestRunner +--- @param group GLuaTest_RunnableTestGroup +function GLuaTest.TestGroupRunner( TestRunner, group ) + --- @class GLuaTest_TestGroupRunner + local TGR = {} + + --- The test group that this runner is Running + --- @type GLuaTest_RunnableTestGroup + TGR.group = group + + --- Shared group-level state + TGR.groupState = {} + + --- Cases that we will run from this group + --- @type GLuaTest_TestCaseRunner[] + TGR.caseRunners = {} + + --- Add a result to the test run + --- @param result GLuaTest_UngroupedTestResult + function TGR:AddResult( result ) + result.testGroup = group + + local groupedResult = result --[[@as GLuaTest_TestResult]] + TestRunner:AddResult( groupedResult ) + end + + --- Add a success result for the given case + --- @param case GLuaTest_RunnableTestCase + function TGR:SetSucceeded( case ) + self:AddResult( { case = case, success = true } ) + end + + --- Add a failed result for the given case + --- @param case GLuaTest_RunnableTestCase + --- @param errInfo? GLuaTest_FailCallbackInfo + function TGR:SetFailed( case, errInfo ) + self:AddResult( { case = case, success = false, errInfo = errInfo } ) + end + + --- Add a timeout result for the given case + --- @param case GLuaTest_RunnableTestCase + function TGR:SetTimedOut( case ) + self:SetFailed( case, { reason = "Timeout" } ) + end + + --- Add a skipped result for the given case + --- @param case GLuaTest_RunnableTestCase + function TGR:SetSkipped( case ) + self:AddResult( { case = case, skipped = true, } ) + end + + --- Add an empty result for the given case + --- @param case GLuaTest_RunnableTestCase + function TGR:SetEmpty( case ) + self:AddResult( { case = case, empty = true, } ) + end + + --- Run an individual test case + --- @param case GLuaTest_TestCase + --- @return GLuaTest_TestCaseRunner + function TGR:MakeCaseRunner( case ) + case.id = Helpers.GetCaseID() + case.state = case.state or Helpers.CreateCaseState( self.groupState ) + case.cleanup = case.cleanup or noop + + local runnableCase = case --[[@as GLuaTest_RunnableTestCase]] + local caseRunner = GLuaTest.TestCaseRunner( self, runnableCase ) + + return caseRunner + end + + --- Run all cases in the test group + --- @param cb fun(): nil The function to run once the group is complete + function TGR:Run( cb ) + local beforeAll = group.beforeAll + if beforeAll then beforeAll( self.groupState ) end + + local runners = self.caseRunners + + local cases = group.cases + local caseCount = #cases + for i = caseCount, 1, -1 do + local case = cases[i] + local runner = self:MakeCaseRunner( case ) + table.insert( runners, runner ) + end + + local function runNext() + local nextRunner = table.remove( runners ) + + if not nextRunner then + local afterAll = group.afterAll + if afterAll then afterAll( self.groupState ) end + + return cb() + end + + timer.Simple( 0, function() + nextRunner:Run( runNext ) + end ) + end + + runNext() + end + + return TGR +end diff --git a/lua/gluatest/stubs/stubMaker.lua b/lua/gluatest/stubs/stubMaker.lua index f5daf59..d3037bd 100644 --- a/lua/gluatest/stubs/stubMaker.lua +++ b/lua/gluatest/stubs/stubMaker.lua @@ -1,7 +1,11 @@ -return function() +--- @alias StubFunction fun(tbl: table, key: any): GLuaTest_Stub +--- @alias GLuaTest_StubMaker fun(): StubFunction, fun() +return function() + --- @type GLuaTest_Stub[] local stubs = {} + --- Cleans up all stubs created by this stub maker local function cleanup() for _, stub in ipairs( stubs ) do if not stub.restored then @@ -10,62 +14,79 @@ return function() end end + --- @param tbl table + --- @param key any return function( tbl, key ) local original = tbl and tbl[key] + --- @class GLuaTest_Stub + --- @field stubbedFunc? function local stubTbl = { + --- Identifies this object as a stub + --- @type true IsStub = true, + + --- How many times this stub has been called callCount = 0, + + --- The arguments passed to this stub for each call + --- @type any[][] callHistory = {}, - restored = false, - Restore = function( self ) - if self.restored then return end - self.restored = true - if not tbl then return end - tbl[key] = original - end, + --- Whether this stub has been restored + --- @type boolean + restored = false } - local meta = { - __index = function( self, idx ) - local stubSet = rawget( self, "stubbedFunc" ) - - if not stubSet then - if idx == "with" then - return function( func ) - rawset( self, "stubbedFunc", func ) - return self - end - end - - if idx == "returns" then - return function( ... ) - local args = { ... } - - rawset( self, "stubbedFunc", function() - return unpack( args ) - end ) - return self - end - end - - if idx == "returnsSequence" then - return function( sequence, default ) - assert( type( sequence ) == "table", "Sequence must be a table" ) - - rawset( self, "stubbedFunc", function() - local ret = sequence[stubTbl.callCount] - if ret == nil then return default end - return ret - end ) - return self - end - end - end + --- Restores the original function + function stubTbl:Restore() + if self.restored then return end + self.restored = true - return rawget( self, idx ) - end, + if not tbl then return end + tbl[key] = original + end + + --- Stubs the function with the provided function + function stubTbl.with( func ) + assert( stubTbl.stubbedFunc == nil, "Stub already set" ) + + stubTbl.stubbedFunc = func + return stubTbl + end + + --- Stubs the function to return the provided value(s) + --- @vararg any + function stubTbl.returns( ... ) + assert( stubTbl.stubbedFunc == nil, "Stub already set" ) + + local args = { ... } + stubTbl.stubbedFunc = function() + return unpack( args ) + end + + return stubTbl + end + + --- Stubs the function to progresively return the provided values as it is called + --- @param sequence any[] The sequence of values to return in order as the function is called + --- @param default? any The value to return if the sequence is exhausted + function stubTbl.returnsSequence( sequence, default ) + assert( stubTbl.stubbedFunc == nil, "Stub already set" ) + assert( type( sequence ) == "table", "Sequence must be a table" ) + + stubTbl.stubbedFunc = function() + local ret = sequence[stubTbl.callCount] + if ret == nil then return default end + + return ret + end + + return stubTbl + end + + local meta = { + __name = "GLuaTest::Stub", __call = function( _, ... ) stubTbl.callCount = stubTbl.callCount + 1 @@ -78,7 +99,6 @@ return function() return nil end, - __name = "GLuaTest::Stub", __tostring = function() local base = "GLuaTest::Stub" if original then diff --git a/lua/gluatest/types.lua b/lua/gluatest/types.lua new file mode 100644 index 0000000..5d1bb20 --- /dev/null +++ b/lua/gluatest/types.lua @@ -0,0 +1,70 @@ +--- @meta GLuaTest_Types + +--- @class GLuaTest_TestState +--- @field [any] any The state of the test + +--- @class GLuaTest_TestCase +--- @field name string The human-readable name of the test +--- @field func fun(state: GLuaTest_TestState): nil The test function +--- @field async? boolean Whether the test requires asynchronous handling +--- @field coroutine? boolean Whether the test requires coroutine handling +--- @field timeout? number The maximum time (in seconds) the test can run before being considered failed +--- @field cleanup? fun(state: GLuaTest_TestState): nil A function to run after the test, regardless of the test outcome +--- @field when? boolean|fun(): boolean Only run this test case when this condition is met +--- @field skip? boolean|fun(): boolean Skip this test case when this condition is met +--- @field clientside? boolean (Not fully supported) Whether the test is clientside +--- @field shared? boolean (Not fully supported) Whether the test is shared + +--- @class GLuaTest_RunnableTestCase : GLuaTest_TestCase +--- The test case with additional information that the Runner needs +--- @field id? string The unique identifier of the test case +--- @field state? GLuaTest_TestState The state of the test case + +--- @class GLuaTest_TestGroup +--- @field cases GLuaTest_TestCase[] The test cases in the group +--- @field groupName? string The human-readable name of the test group (Defaults to the path of the test) +--- @field beforeAll? fun(state: GLuaTest_TestState): nil A function to run before all tests in the group +--- @field beforeEach? fun(state: GLuaTest_TestState): nil A function to run before each test in the group +--- @field afterAll? fun(state: GLuaTest_TestState): nil A function to run after all tests in the group +--- @field afterEach? fun(state: GLuaTest_TestState): nil A function to run after each test in the group + +--- @class GLuaTest_RunnableTestGroup : GLuaTest_TestGroup +--- @field fileName string The name of the file the test is in +--- @field project string The name of the project the test is in + +--- @class GLuaTest_UngroupedTestResult +--- @field case GLuaTest_TestCase The test case +--- @field empty? boolean Whether the test case was empty +--- @field success? boolean Whether the test succeeded +--- @field skipped? boolean Whether the test was skipped +--- @field errInfo? GLuaTest_FailCallbackInfo The error information if the test failed + +--- @class GLuaTest_TestResult : GLuaTest_UngroupedTestResult +--- @field testGroup GLuaTest_RunnableTestGroup The test group + + +--- @class GLuaTest_CaseSuccess +--- @field result '"success"' + +--- @class GLuaTest_CaseFailure +--- @field result '"failure"' +--- @field errInfo GLuaTest_FailCallbackInfo + +--- @class GLuaTest_CaseEmpty +--- @field result '"empty"' + +--- @alias GLuaTest_CaseRunResult GLuaTest_CaseSuccess | GLuaTest_CaseFailure | GLuaTest_CaseEmpty + + +--- Begin an expectation chain +--- @param subject any The subject of the expectation +--- @return GLuaTest_Expect +function expect( subject, ... ) +end + +--- Create a stub function +--- @param tbl table The table to stub +--- @param key any The key to stub +--- @return GLuaTest_Stub +function stub( tbl, key ) +end diff --git a/lua/tests/gluatest/init.lua b/lua/tests/gluatest/init.lua new file mode 100644 index 0000000..6d81ce2 --- /dev/null +++ b/lua/tests/gluatest/init.lua @@ -0,0 +1,28 @@ +return { + groupName = "Initialization", + + cases = { + { + name = "Table exists", + func = function() + expect( _G.GLuaTest ).to.exist() + end + }, + + { + name = "gluatest_run_tests concommand exists", + func = function() + local commands = concommand.GetTable() + expect( commands["gluatest_run_tests"] ).to.beA( "function" ) + end + }, + + { + name = "Convars exist", + func = function() + expect( ConVarExists( "gluatest_use_ansi" ) ).to.beTrue() + expect( ConVarExists( "gluatest_enable" ) ).to.beTrue() + end + } + } +} diff --git a/lua/tests/gluatest/loader/checkSendToClients.lua b/lua/tests/gluatest/loader/checkSendToClients.lua new file mode 100644 index 0000000..1a3a111 --- /dev/null +++ b/lua/tests/gluatest/loader/checkSendToClients.lua @@ -0,0 +1,73 @@ +return { + groupName = "checkSendToClients", + beforeEach = function( state ) + state.Loader = include( "gluatest/loader.lua" ) + end, + + cases = { + { + name = "Exists on the Loader table", + func = function( state ) + local Loader = state.Loader + + expect( Loader.checkSendToClients ).to.beA( "function" ) + end + }, + + { + name = "Sends clientside cases when RUN_CLIENTSIDE is enabled", + func = function( state ) + local Loader = state.Loader + state.currentRunClientside = GLuaTest.RUN_CLIENTSIDE + + GLuaTest.RUN_CLIENTSIDE = true + local AddCSLuaFileStub = stub( _G, "AddCSLuaFile" ) + + local cases = { { clientside = true } } + Loader.checkSendToClients( "test.lua", cases ) + expect( AddCSLuaFileStub ).was.called() + end, + cleanup = function( state ) + GLuaTest.RUN_CLIENTSIDE = state.currentRunClientside + end + }, + + { + name = "Does not send clientside cases if RUN_CLIENTSIDE is disabled", + func = function( state ) + local Loader = state.Loader + state.currentRunClientside = GLuaTest.RUN_CLIENTSIDE + + GLuaTest.RUN_CLIENTSIDE = false + local AddCSLuaFileStub = stub( _G, "AddCSLuaFile" ) + + local cases = { { clientside = true } } + Loader.checkSendToClients( "test.lua", cases ) + expect( AddCSLuaFileStub ).wasNot.called() + end, + + cleanup = function( state ) + GLuaTest.RUN_CLIENTSIDE = state.currentRunClientside + end + }, + + { + name = "Does not send non-clientside cases when RUN_CLIENTSIDE is enabled", + func = function( state ) + local Loader = state.Loader + state.currentRunClientside = GLuaTest.RUN_CLIENTSIDE + + GLuaTest.RUN_CLIENTSIDE = true + local AddCSLuaFileStub = stub( _G, "AddCSLuaFile" ) + + local cases = { { clientside = false } } + Loader.checkSendToClients( "test.lua", cases ) + expect( AddCSLuaFileStub ).wasNot.called() + end, + + cleanup = function( state ) + GLuaTest.RUN_CLIENTSIDE = state.currentRunClientside + end + } + } +} diff --git a/lua/tests/gluatest/loader/getProjectName.lua b/lua/tests/gluatest/loader/getProjectName.lua new file mode 100644 index 0000000..6ed9f9f --- /dev/null +++ b/lua/tests/gluatest/loader/getProjectName.lua @@ -0,0 +1,31 @@ +return { + groupName = "getProjectName", + beforeEach = function( state ) + state.Loader = include( "gluatest/loader.lua" ) + end, + + cases = { + { + name = "Exists on the Loader table", + func = function( state ) + local Loader = state.Loader + + expect( Loader.getProjectName ).to.beA( "function" ) + end + }, + + { + name = "Returns the project name", + func = function( state ) + local Loader = state.Loader + local getProjectName = Loader.getProjectName + + expect( getProjectName( "addons/testAddon/lua/tests/project1/file1.lua" ) ).to.equal( "project1" ) + expect( getProjectName( "tests/project1/file1.lua" ) ).to.equal( "project1" ) + expect( getProjectName( "tests/project2/module/file2.lua" ) ).to.equal( "project2/module" ) + expect( getProjectName( "adskfjadslfjdaslfadskj" ) ).to.equal( nil ) + end + } + + } +} diff --git a/lua/tests/gluatest/runner/colors.lua b/lua/tests/gluatest/runner/colors.lua new file mode 100644 index 0000000..e308aca --- /dev/null +++ b/lua/tests/gluatest/runner/colors.lua @@ -0,0 +1,34 @@ +local function getColors() + return include( "gluatest/runner/colors.lua" ) +end + +return { + groupName = "Colors", + + cases = { + { + name = "Returns a table of colors", + func = function() + stub( hook, "Run" ) + local colors = getColors() + + for name, value in pairs( colors ) do + expect( name ).to.beA( "string" ) + expect( IsColor( value ) ).to.beTrue() + end + end + }, + + { + name = "Calls hook.Run when the color table is created", + func = function() + local hookRunStub = stub( hook, "Run" ) --[[@as GLuaTest_Stub]] + + local colors = getColors() + + local callHistory = hookRunStub.callHistory + expect( callHistory[1] ).to.deepEqual( { "GLuaTest_MakeColors", colors } ) + end + } + } +} diff --git a/lua/tests/gluatest/runner/log_helpers/GenerateDivider.lua b/lua/tests/gluatest/runner/log_helpers/GenerateDivider.lua new file mode 100644 index 0000000..727a416 --- /dev/null +++ b/lua/tests/gluatest/runner/log_helpers/GenerateDivider.lua @@ -0,0 +1,74 @@ +local LogHelpers = include( "gluatest/runner/log_helpers.lua" ) +local GenerateDivider = LogHelpers.GenerateDivider + +return { + groupName = "GenerateDivider", + + cases = { + { + name = "Generates divider based on longest line length and reason length", + func = function() + local lines = { "Short line", "This is a longer line of text" } + local reason = "Reason for failure" + local result = GenerateDivider( lines, reason ) + + local expected = #lines[2] + #reason + expect( #result ).to.equal( expected ) + end + }, + { + name = "Respects 110 character limit", + func = function() + local lines = { "A very long line that is repeated multiple times to exceed the limit" } + local reason = "This reason text is also quite long to test the limit." + local result = GenerateDivider( lines, reason ) + + local total = #lines[1] + #reason + expect( total ).to.beGreaterThan( 110 ) + expect( #result ).to.equal( 110 ) + end + }, + { + name = "Handles case with very short lines and reason", + func = function() + local lines = { "Tiny", "Small" } + local reason = "Oops!" + local result = LogHelpers.GenerateDivider( lines, reason ) + + local expected = #lines[2] + #reason + expect( #result ).to.equal( expected ) + end + }, + { + name = "Returns and empty string when empty lines and reason", + func = function() + local lines = { "", "", "" } + local reason = "" + local result = LogHelpers.GenerateDivider( lines, reason ) + + expect( result ).to.equal( "" ) + end + }, + { + name = "Handles empty reason with non-empty lines", + func = function() + local lines = { "Line with some content", "Another line" } + local reason = "" + local result = LogHelpers.GenerateDivider( lines, reason ) + + local expected = #lines[1] + expect( #result ).to.equal( expected ) + end + }, + { + name = "Handles empty lines with non-empty reason", + func = function() + local lines = { "", "", "" } + local reason = "Failure reason" + local result = LogHelpers.GenerateDivider( lines, reason ) + + expect( #result ).to.equal( #reason ) + end + } + } +} diff --git a/lua/tests/gluatest/runner/log_helpers/GetLeadingWhitespace.lua b/lua/tests/gluatest/runner/log_helpers/GetLeadingWhitespace.lua new file mode 100644 index 0000000..618611e --- /dev/null +++ b/lua/tests/gluatest/runner/log_helpers/GetLeadingWhitespace.lua @@ -0,0 +1,65 @@ +local LogHelpers = include( "gluatest/runner/log_helpers.lua" ) +local GetLeadingWhitespace = LogHelpers.GetLeadingWhitespace + +return { + groupName = "GetLeadingWhitespace", + + cases = { + { + name = "Returns empty string for no leading whitespace", + func = function() + local result = GetLeadingWhitespace( "NoLeadingWhitespace" ) + expect( result ).to.equal( "" ) + end + }, + { + name = "Returns single space for one leading space", + func = function() + local result = GetLeadingWhitespace( " SingleSpace" ) + expect( result ).to.equal( " " ) + end + }, + { + name = "Returns multiple spaces for leading spaces", + func = function() + local result = GetLeadingWhitespace( " FourSpaces" ) + expect( result ).to.equal( " " ) + end + }, + { + name = "Returns tabs as leading whitespace", + func = function() + local result = GetLeadingWhitespace( "\t\tTabsBeforeText" ) + expect( result ).to.equal( "\t\t" ) + end + }, + { + name = "Returns mixed spaces and tabs as leading whitespace", + func = function() + local result = GetLeadingWhitespace( " \t MixedWhitespace" ) + expect( result ).to.equal( " \t " ) + end + }, + { + name = "Returns empty string for an empty line", + func = function() + local result = LogHelpers.GetLeadingWhitespace( "" ) + expect( result ).to.equal( "" ) + end + }, + { + name = "Returns empty string for a line with no whitespace and special characters", + func = function() + local result = LogHelpers.GetLeadingWhitespace( "!@#%&" ) + expect( result ).to.equal( "" ) + end + }, + { + name = "Handles only whitespace input", + func = function() + local result = LogHelpers.GetLeadingWhitespace( " " ) + expect( result ).to.equal( " " ) + end + } + } +} diff --git a/lua/tests/gluatest/runner/log_helpers/GetLineWithContext.lua b/lua/tests/gluatest/runner/log_helpers/GetLineWithContext.lua new file mode 100644 index 0000000..7392208 --- /dev/null +++ b/lua/tests/gluatest/runner/log_helpers/GetLineWithContext.lua @@ -0,0 +1,95 @@ +local LogHelpers = include( "gluatest/runner/log_helpers.lua" ) +local GetLineWithContext = LogHelpers.GetLineWithContext + +return { + groupName = "GetLineWithContext", + + beforeEach = function( state ) + state.fileStub = stub( LogHelpers, "getFileLines" ).with( function() + return { + "Line 1", + "Line 2", + "Line 3", + "Line 4", + "Line 5", + "Line 6", + "Line 7", + "Line 8", + "Line 9", + "Line 10" + } + end ) + end, + + cases = { + { + name = "Retrieves line with default context", + func = function() + local result = GetLineWithContext( "dummy_path", 5 ) + expect( result ).to.deepEqual( { + "Line 1", + "Line 2", + "Line 3", + "Line 4", + "Line 5" + } ) + end + }, + { + name = "Retrieves line with specified context", + func = function() + local result = GetLineWithContext( "dummy_path", 5, 2 ) + expect( result ).to.deepEqual( { + "Line 3", + "Line 4", + "Line 5" + } ) + end + }, + { + name = "Handles context at the beginning of the file", + func = function() + local result = GetLineWithContext( "dummy_path", 2 ) + expect( result ).to.deepEqual( { + "Line 1", + "Line 2" + } ) + end + }, + { + name = "Handles context at the end of the file", + func = function() + local result = GetLineWithContext( "dummy_path", 10 ) + expect( result ).to.deepEqual( { + "Line 5", + "Line 6", + "Line 7", + "Line 8", + "Line 9", + "Line 10" + } ) + end + }, + { + name = "Handles no context (context set to 0)", + func = function() + local result = GetLineWithContext( "dummy_path", 5, 0 ) + expect( result ).to.deepEqual( { "Line 5" } ) + end + }, + { + name = "Handles out-of-bounds line number", + func = function() + local result = GetLineWithContext( "dummy_path", 15 ) + expect( result ).to.deepEqual( { "Line 10" } ) + end + }, + { + name = "Handles out-of-bounds line number with no context", + func = function() + local result = GetLineWithContext( "dummy_path", 15, 0 ) + expect( result ).to.deepEqual( {} ) + end + } + } +} diff --git a/lua/tests/gluatest/runner/log_helpers/NormalizeLineIndent.lua b/lua/tests/gluatest/runner/log_helpers/NormalizeLineIndent.lua new file mode 100644 index 0000000..f6b7fe8 --- /dev/null +++ b/lua/tests/gluatest/runner/log_helpers/NormalizeLineIndent.lua @@ -0,0 +1,117 @@ +local LogHelpers = include( "gluatest/runner/log_helpers.lua" ) +local NormalizeLinesIndent = LogHelpers.NormalizeLinesIndent + +return { + groupName = "NormalizeLinesIndent", + + cases = { + { + name = "Dedents lines with uniform indentation", + func = function() + local lines = { + " Line with four spaces", + " Another line with four spaces" + } + + local result = NormalizeLinesIndent( lines ) + expect( result[1] ).to.equal( "Line with four spaces" ) + expect( result[2] ).to.equal( "Another line with four spaces" ) + end + }, + { + name = "Dedents lines with varying indentation", + func = function() + local lines = { + " Eight spaces", + " Four spaces", + " Twelve spaces" + } + + local result = NormalizeLinesIndent( lines ) + expect( result[1] ).to.equal( " Eight spaces" ) + expect( result[2] ).to.equal( "Four spaces" ) + expect( result[3] ).to.equal( " Twelve spaces" ) + end + }, + { + name = "Handles already unindented lines", + func = function() + local lines = { + "No indent here", + "Also no indent" + } + + local result = NormalizeLinesIndent( lines ) + expect( result[1] ).to.equal( "No indent here" ) + expect( result[2] ).to.equal( "Also no indent" ) + end + }, + { + name = "Returns lines unchanged if only empty lines", + func = function() + local lines = { + "", + "", + "" + } + + local result = NormalizeLinesIndent( lines ) + expect( result[1] ).to.equal( "" ) + expect( result[2] ).to.equal( "" ) + expect( result[3] ).to.equal( "" ) + end + }, + { + name = "Dedents lines with mixed content and empty lines", + func = function() + local lines = { + " Four spaces", + "", + " Another line with four spaces", + "" + } + + local result = NormalizeLinesIndent( lines ) + expect( result[1] ).to.equal( "Four spaces" ) + expect( result[2] ).to.equal( "" ) + expect( result[3] ).to.equal( "Another line with four spaces" ) + expect( result[4] ).to.equal( "" ) + end + }, + + { + name = "Dedents a single indented line", + func = function() + local lines = { + " Single line with eight spaces" + } + + local result = NormalizeLinesIndent( lines ) + expect( result[1] ).to.equal( "Single line with eight spaces" ) + end + }, + { + name = "Dedents lines containing only whitespace", + func = function() + local lines = { + " ", + " ", + " " + } + + local result = NormalizeLinesIndent( lines ) + expect( result[1] ).to.equal( " " ) + expect( result[2] ).to.equal( "" ) + expect( result[3] ).to.equal( " " ) + end + }, + { + name = "Returns empty array for empty input", + func = function() + local lines = {} + local result = NormalizeLinesIndent( lines ) + expect( #result ).to.equal( 0 ) + end + } + } +} diff --git a/lua/tests/gluatest/runner/log_helpers/cleanPathForRead.lua b/lua/tests/gluatest/runner/log_helpers/cleanPathForRead.lua new file mode 100644 index 0000000..b7e5762 --- /dev/null +++ b/lua/tests/gluatest/runner/log_helpers/cleanPathForRead.lua @@ -0,0 +1,66 @@ +local LogHelpers = include( "gluatest/runner/log_helpers.lua" ) +local cleanPathForRead = LogHelpers.cleanPathForRead + +return { + groupName = "cleanPathForRead", + + cases = { + { + name = "Exists on the LogHelpers table", + func = function() + expect( LogHelpers.cleanPathForRead ).to.beA( "function" ) + end + }, + + { + name = "Converts path from addons directory", + func = function() + local path = "addons/addon_name/lua/tests/addon_name/test.lua" + local result = cleanPathForRead( path ) + expect( result ).to.equal( "tests/addon_name/test.lua" ) + end + }, + + { + name = "Converts path from gamemodes directory", + func = function() + local path = "gamemodes/darkrp/gamemode/tests/darkrp/main.lua" + local result = cleanPathForRead( path ) + expect( result ).to.equal( "darkrp/gamemode/tests/darkrp/main.lua" ) + end + }, + + { + name = "Handles path with subdirectories after lua", + func = function() + local path = "lua/project/module/tests/test_file.lua" + local result = cleanPathForRead( path ) + expect( result ).to.equal( "project/module/tests/test_file.lua" ) + end + }, + + { + name = "Returns path when 'lua' is root directory", + func = function() + local path = "lua/test.lua" + local result = cleanPathForRead( path ) + expect( result ).to.equal( "test.lua" ) + end + }, + { + name = "Returns path when 'gamemodes' is root directory", + func = function() + local path = "gamemodes/othergamemode/file.lua" + local result = cleanPathForRead( path ) + expect( result ).to.equal( "othergamemode/file.lua" ) + end + }, + { + name = "Errors on incomplete path following lua", + func = function() + local path = "addons/lua" + expect( cleanPathForRead, path ).to.err() + end + } + } +} diff --git a/lua/tests/gluatest/runner/log_helpers/getFileLines.lua b/lua/tests/gluatest/runner/log_helpers/getFileLines.lua new file mode 100644 index 0000000..21ac240 --- /dev/null +++ b/lua/tests/gluatest/runner/log_helpers/getFileLines.lua @@ -0,0 +1,114 @@ +local LogHelpers = include( "gluatest/runner/log_helpers.lua" ) + +-- Define getFileLines locally for testing +local getFileLines = LogHelpers.getFileLines + +-- Temporary fileLinesCache for test isolation +local function makeFakeCache() + return { + cache = {}, + get = function( self, key ) return self.cache[key] end, + set = function( self, key, value ) self.cache[key] = value end + } +end + +local function fakeFileOpenResponse( read, size ) + return { + Read = function() return read end, + Size = function() return size end, + Close = function() end + } +end + +return { + groupName = "LogHelpers.getFileLines Tests", + + beforeEach = function() + LogHelpers.fileLinesCache = makeFakeCache() + end, + + cases = { + { + name = "Reads file and returns lines", + func = function() + local filePath = "addons/testaddon/lua/tests/sample.txt" + + stub( file, "Open" ).returns( + fakeFileOpenResponse( "Line1\nLine2\nLine3", 14 ) + ) + + local lines = getFileLines( filePath ) + expect( #lines ).to.equal( 3 ) + expect( lines[1] ).to.equal( "Line1" ) + expect( lines[2] ).to.equal( "Line2" ) + expect( lines[3] ).to.equal( "Line3" ) + end + }, + { + name = "Returns cached lines on second read", + func = function() + local filePath = "addons/testaddon/lua/tests/sample.txt" + + local fileStub = stub( file, "Open" ).returns( + fakeFileOpenResponse( "CachedLine1\nCachedLine2", 19 ) + ) + + -- First call should open the file + local initialLines = getFileLines( filePath ) + expect( #initialLines ).to.equal( 2 ) + expect( initialLines[1] ).to.equal( "CachedLine1" ) + expect( initialLines[2] ).to.equal( "CachedLine2" ) + expect( fileStub ).was.called() + + fileStub:Restore() + fileStub = stub( file, "Open" ).returns( + fakeFileOpenResponse( "CachedLine1\nCachedLine2", 19 ) + ) + + -- Second call should use cache and not invoke file.Open + local cachedLines = getFileLines( filePath ) + expect( #cachedLines ).to.equal( 2 ) + expect( cachedLines[1] ).to.equal( "CachedLine1" ) + expect( cachedLines[2] ).to.equal( "CachedLine2" ) + expect( fileStub ).wasNot.called() + end + }, + { + name = "Handles single line file correctly", + func = function() + local filePath = "addons/testaddon/lua/tests/single_line.txt" + + stub( file, "Open" ).returns( + fakeFileOpenResponse( "SingleLineContent", 16 ) + ) + + local lines = getFileLines( filePath ) + expect( #lines ).to.equal( 1 ) + expect( lines[1] ).to.equal( "SingleLineContent" ) + end + }, + { + name = "Handles empty file correctly", + func = function() + local filePath = "addons/testaddon/lua/tests/empty.txt" + + stub( file, "Open" ).returns( + fakeFileOpenResponse( "", 0 ) + ) + + local lines = getFileLines( filePath ) + expect( #lines ).to.equal( 1 ) + expect( lines[1] ).to.equal( "" ) + end + }, + { + name = "Errors on non-existent file", + func = function() + local filePath = "addons/testaddon/lua/tests/non_existent.txt" + + stub( file, "Open" ).returns( nil ) + expect( getFileLines, filePath ).to.err() + end + } + } +} diff --git a/lua/tests/gluatest/runner/log_helpers/getLeastSharedIndent.lua b/lua/tests/gluatest/runner/log_helpers/getLeastSharedIndent.lua new file mode 100644 index 0000000..f8982e7 --- /dev/null +++ b/lua/tests/gluatest/runner/log_helpers/getLeastSharedIndent.lua @@ -0,0 +1,73 @@ +local LogHelpers = include( "gluatest/runner/log_helpers.lua" ) +local getLeastSharedIndent = LogHelpers.getLeastSharedIndent + +return { + groupName = "getLeastSharedIndent", + + cases = { + { + name = "Calculates least shared indent for uniform indentation", + func = function() + local lines = { + " Line with indent", + " Another line with same indent" + } + + local result = getLeastSharedIndent( lines ) + expect( result ).to.equal( 4 ) + end + }, + { + name = "Handles lines with varying indentation correctly", + func = function() + local lines = { + " Eight spaces", + " Four spaces", + " Twelve spaces" + } + + local result = getLeastSharedIndent( lines ) + expect( result ).to.equal( 4 ) + end + }, + { + name = "Ignores empty lines", + func = function() + local lines = { + " Line with indent", + "", + " Another line with indent", + "" + } + + local result = getLeastSharedIndent( lines ) + expect( result ).to.equal( 4 ) + end + }, + { + name = "Returns 0 for unindented lines", + func = function() + local lines = { + "No indent here", + "Also no indent" + } + + local result = getLeastSharedIndent( lines ) + expect( result ).to.equal( 0 ) + end + }, + { + name = "Returns 0 for all empty lines", + func = function() + local lines = { + "", + "", + "" + } + + local result = getLeastSharedIndent( lines ) + expect( result ).to.equal( 0 ) + end + } + } +}