/** * Lua Test Runner Integration * TypeScript utilities for running Lua tests from packages */ type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } export interface LuaTestResult { name: string status: 'passed' | 'failed' | 'skipped' | 'pending' error?: string expected?: JsonValue actual?: JsonValue duration: number } export interface LuaSuiteResult { name: string tests: LuaTestResult[] nested: LuaSuiteResult[] stats: { total: number passed: number failed: number skipped: number duration: number } } export interface LuaTestRunResult { suites: LuaSuiteResult[] stats: { total: number passed: number failed: number skipped: number duration: number suiteCount: number success: boolean } timestamp: string } export interface LuaTestConfig { timeout?: number verbose?: boolean stopOnFirstFailure?: boolean filter?: string } export interface TestCaseFiles { [filename: string]: string // filename -> JSON content } /** * Generates Lua code that runs tests and returns results * This code uses the lua_test framework modules */ export function generateTestRunnerCode( testCode: string, config: LuaTestConfig = {}, testCaseFiles: TestCaseFiles = {} ): string { const configJson = JSON.stringify(config) const testCaseFilesJson = JSON.stringify(testCaseFiles) return ` -- Load lua_test framework modules local framework = (function() ${getFrameworkModule()} end)() local assertions = (function() ${getAssertionsModule()} end)() local mocks = (function() ${getMocksModule()} end)() local runner = (function() ${getRunnerModule()} end)() local helpers = (function() ${getHelpersModule()} end)() local json_parser = (function() ${getJsonParserModule()} end)() -- Test case files loaded from package local _test_case_files = ${testCaseFilesJson} -- Load test cases from a JSON file function load_cases(filename, path) local content = _test_case_files[filename] if not content then error("Test case file not found: " .. filename) end local data = json_parser.decode(content) if path then for segment in string.gmatch(path, "[^%.]+") do if data and type(data) == "table" then data = data[segment] else error("Invalid path: " .. path) end end end return data or {} end -- Configure framework local config = ${configJson} if config.timeout then framework.configure({ timeout = config.timeout }) end if config.stopOnFirstFailure then framework.configure({ stopOnFirstFailure = config.stopOnFirstFailure }) end if config.filter then framework.configure({ filter = config.filter }) end -- Expose test DSL globally for the test code describe = framework.describe it = framework.it xit = framework.xit fit = framework.fit beforeAll = framework.beforeAll afterAll = framework.afterAll beforeEach = framework.beforeEach afterEach = framework.afterEach it_each = framework.it_each fit_each = framework.fit_each xit_each = framework.xit_each expect = assertions.expect assertTrue = assertions.assertTrue assertFalse = assertions.assertFalse assertEqual = assertions.assertEqual assertNotEqual = assertions.assertNotEqual assertNil = assertions.assertNil assertNotNil = assertions.assertNotNil fail = assertions.fail mock = mocks -- Reset any previous test state framework.reset() -- Execute the test definitions ${testCode} -- Run all registered tests local results = runner.runAll(framework.getSuites(), config) -- Return results return results ` } /** * Inlined framework module code (simplified for embedding) */ function getFrameworkModule(): string { return ` local M = {} M._suites = {} M._currentSuite = nil M._config = { timeout = 5000, verbose = true, stopOnFirstFailure = false, filter = nil } function M.createSuite(name) return { name = name, tests = {}, beforeAll = nil, afterAll = nil, beforeEach = nil, afterEach = nil, nested = {}, parent = nil } end function M.describe(name, fn) local parentSuite = M._currentSuite local suite = M.createSuite(name) suite.parent = parentSuite if parentSuite then parentSuite.nested[#parentSuite.nested + 1] = suite else M._suites[#M._suites + 1] = suite end M._currentSuite = suite fn() M._currentSuite = parentSuite return suite end function M.it(name, fn) if not M._currentSuite then error("it() must be called inside describe()") end local test = { name = name, fn = fn, status = "pending", error = nil, duration = 0, skipped = false } M._currentSuite.tests[#M._currentSuite.tests + 1] = test return test end function M.xit(name, fn) if not M._currentSuite then error("xit() must be called inside describe()") end local test = { name = name, fn = fn, status = "skipped", skipped = true } M._currentSuite.tests[#M._currentSuite.tests + 1] = test return test end function M.fit(name, fn) local test = M.it(name, fn) test.only = true return test end -- Parameterized test helper local function create_each(testFn) return function(cases) return function(nameTemplate, fn) for _, testCase in ipairs(cases) do local name = nameTemplate for key, value in pairs(testCase) do local strValue = type(value) == "table" and "[table]" or tostring(value) name = string.gsub(name, "%$" .. key, strValue) end testFn(name, function() fn(testCase) end) end end end end function M.it_each(cases) return create_each(M.it)(cases) end function M.fit_each(cases) return create_each(M.fit)(cases) end function M.xit_each(cases) return create_each(M.xit)(cases) end function M.beforeAll(fn) if M._currentSuite then M._currentSuite.beforeAll = fn end end function M.afterAll(fn) if M._currentSuite then M._currentSuite.afterAll = fn end end function M.beforeEach(fn) if M._currentSuite then M._currentSuite.beforeEach = fn end end function M.afterEach(fn) if M._currentSuite then M._currentSuite.afterEach = fn end end function M.configure(options) for k, v in pairs(options) do if M._config[k] ~= nil then M._config[k] = v end end end function M.getSuites() return M._suites end function M.reset() M._suites = {} M._currentSuite = nil end function M.getConfig() return M._config end return M ` } function getAssertionsModule(): string { return ` local M = {} local function stringify(value) local t = type(value) if t == "string" then return '"' .. value .. '"' elseif t == "table" then local parts = {} for k, v in pairs(value) do parts[#parts + 1] = tostring(k) .. "=" .. stringify(v) end return "{" .. table.concat(parts, ", ") .. "}" elseif t == "nil" then return "nil" else return tostring(value) end end local function deepEqual(a, b) if type(a) ~= type(b) then return false end if type(a) ~= "table" then return a == b end for k, v in pairs(a) do if not deepEqual(v, b[k]) then return false end end for k, _ in pairs(b) do if a[k] == nil then return false end end return true end local function assertionError(message, expected, actual) return { type = "AssertionError", message = message, expected = expected, actual = actual } end function M.expect(actual) local expectation = { actual = actual, negated = false } expectation.never = setmetatable({}, { __index = function(_, key) expectation.negated = true return expectation[key] end }) function expectation.toBe(expected) local pass = actual == expected if expectation.negated then pass = not pass end if not pass then local msg = expectation.negated and "Expected " .. stringify(actual) .. " not to be " .. stringify(expected) or "Expected " .. stringify(actual) .. " to be " .. stringify(expected) error(assertionError(msg, expected, actual)) end return true end function expectation.toEqual(expected) local pass = deepEqual(actual, expected) if expectation.negated then pass = not pass end if not pass then error(assertionError(expectation.negated and "Expected values not to be deeply equal" or "Expected values to be deeply equal", expected, actual)) end return true end function expectation.toBeNil() local pass = actual == nil if expectation.negated then pass = not pass end if not pass then error(assertionError(expectation.negated and "Expected not nil" or "Expected nil", nil, actual)) end return true end function expectation.toBeTruthy() local pass = actual and true or false if expectation.negated then pass = not pass end if not pass then error(assertionError(expectation.negated and "Expected falsy" or "Expected truthy", "truthy", actual)) end return true end function expectation.toBeFalsy() local pass = not actual if expectation.negated then pass = not pass end if not pass then error(assertionError(expectation.negated and "Expected truthy" or "Expected falsy", "falsy", actual)) end return true end function expectation.toBeType(expectedType) local actualType = type(actual) local pass = actualType == expectedType if expectation.negated then pass = not pass end if not pass then error(assertionError("Type mismatch", expectedType, actualType)) end return true end function expectation.toContain(expected) local pass = false if type(actual) == "string" and type(expected) == "string" then pass = string.find(actual, expected, 1, true) ~= nil elseif type(actual) == "table" then for _, v in pairs(actual) do if deepEqual(v, expected) then pass = true break end end end if expectation.negated then pass = not pass end if not pass then error(assertionError("toContain failed", expected, actual)) end return true end function expectation.toHaveLength(expectedLength) local actualLength = type(actual) == "string" and #actual or (type(actual) == "table" and #actual or 0) local pass = actualLength == expectedLength if expectation.negated then pass = not pass end if not pass then error(assertionError("Length mismatch", expectedLength, actualLength)) end return true end function expectation.toBeGreaterThan(expected) local pass = actual > expected if expectation.negated then pass = not pass end if not pass then error(assertionError("toBeGreaterThan failed", "> " .. expected, actual)) end return true end function expectation.toBeLessThan(expected) local pass = actual < expected if expectation.negated then pass = not pass end if not pass then error(assertionError("toBeLessThan failed", "< " .. expected, actual)) end return true end function expectation.toBeCloseTo(expected, precision) precision = precision or 2 local diff = math.abs(actual - expected) local pass = diff < (10 ^ -precision) / 2 if expectation.negated then pass = not pass end if not pass then error(assertionError("toBeCloseTo failed", expected, actual)) end return true end function expectation.toMatch(pattern) local pass = string.match(actual, pattern) ~= nil if expectation.negated then pass = not pass end if not pass then error(assertionError("Pattern match failed", pattern, actual)) end return true end function expectation.toThrow(expectedMessage) local success, err = pcall(actual) local pass = not success if pass and expectedMessage then local errMsg = type(err) == "table" and err.message or tostring(err) pass = string.find(errMsg, expectedMessage, 1, true) ~= nil end if expectation.negated then pass = not pass end if not pass then error(assertionError("toThrow failed", expectedMessage or "error", success and "no error" or err)) end return true end function expectation.toHaveProperty(key, value) local pass = actual[key] ~= nil if pass and value ~= nil then pass = deepEqual(actual[key], value) end if expectation.negated then pass = not pass end if not pass then error(assertionError("toHaveProperty failed", value, actual[key])) end return true end return expectation end function M.assertTrue(value, message) if not value then error(assertionError(message or "Expected true", true, value)) end end function M.assertFalse(value, message) if value then error(assertionError(message or "Expected false", false, value)) end end function M.assertEqual(actual, expected, message) if actual ~= expected then error(assertionError(message or "Values not equal", expected, actual)) end end function M.assertNotEqual(actual, expected, message) if actual == expected then error(assertionError(message or "Values should not be equal", "not equal", actual)) end end function M.assertNil(value, message) if value ~= nil then error(assertionError(message or "Expected nil", nil, value)) end end function M.assertNotNil(value, message) if value == nil then error(assertionError(message or "Expected not nil", "not nil", nil)) end end function M.fail(message) error(assertionError(message or "Test failed", nil, nil)) end return M ` } function getMocksModule(): string { return ` local M = {} function M.fn(implementation) local mock = { calls = {}, results = {}, implementation = implementation } local callable = function(...) local args = {...} mock.calls[#mock.calls + 1] = args local result = mock.implementation and {mock.implementation(...)} or {} mock.results[#mock.results + 1] = result return table.unpack(result) end return setmetatable({}, { __call = function(_, ...) return callable(...) end, __index = { getCallCount = function() return #mock.calls end, wasCalled = function() return #mock.calls > 0 end, wasCalledWith = function(...) local expectedArgs = {...} for _, callArgs in ipairs(mock.calls) do local match = true for i, expected in ipairs(expectedArgs) do if callArgs[i] ~= expected then match = false break end end if match then return true end end return false end, getCall = function(index) return mock.calls[index] end, getLastCall = function() return mock.calls[#mock.calls] end, getCalls = function() return mock.calls end, getResults = function() return mock.results end, reset = function() mock.calls = {} mock.results = {} end, mockReturnValue = function(value) mock.implementation = function() return value end end, mockImplementation = function(fn) mock.implementation = fn end, mockRestore = function() mock.implementation = nil mock.calls = {} mock.results = {} end } }) end function M.spyOn(obj, methodName) local original = obj[methodName] local spy = M.fn(original) local meta = getmetatable(spy).__index local originalRestore = meta.mockRestore meta.mockRestore = function() obj[methodName] = original originalRestore() end obj[methodName] = function(...) return spy(...) end return spy end return M ` } function getRunnerModule(): string { return ` local M = {} M.STATUS = { PASSED = "passed", FAILED = "failed", SKIPPED = "skipped", PENDING = "pending" } function M.runTest(test, hooks) local result = { name = test.name, status = M.STATUS.PENDING, error = nil, duration = 0 } if test.skipped then result.status = M.STATUS.SKIPPED return result end local startTime = os.clock() if hooks.beforeEach then local success, err = pcall(hooks.beforeEach) if not success then result.status = M.STATUS.FAILED result.error = "beforeEach failed: " .. tostring(err) result.duration = (os.clock() - startTime) * 1000 return result end end local success, err = pcall(test.fn) if hooks.afterEach then pcall(hooks.afterEach) end result.duration = (os.clock() - startTime) * 1000 if success then result.status = M.STATUS.PASSED else result.status = M.STATUS.FAILED if type(err) == "table" and err.type == "AssertionError" then result.error = err.message result.expected = err.expected result.actual = err.actual else result.error = tostring(err) end end return result end function M.runSuite(suite, config, parentHooks) local results = { name = suite.name, tests = {}, nested = {}, stats = { total = 0, passed = 0, failed = 0, skipped = 0, duration = 0 } } config = config or {} parentHooks = parentHooks or {} local startTime = os.clock() local hooks = { beforeEach = suite.beforeEach or parentHooks.beforeEach, afterEach = suite.afterEach or parentHooks.afterEach } if suite.beforeAll then local success, err = pcall(suite.beforeAll) if not success then for _, test in ipairs(suite.tests) do results.tests[#results.tests + 1] = { name = test.name, status = M.STATUS.FAILED, error = "beforeAll failed: " .. tostring(err), duration = 0 } results.stats.failed = results.stats.failed + 1 results.stats.total = results.stats.total + 1 end results.stats.duration = (os.clock() - startTime) * 1000 return results end end local hasOnly = false for _, test in ipairs(suite.tests) do if test.only then hasOnly = true break end end for _, test in ipairs(suite.tests) do if hasOnly and not test.only then results.tests[#results.tests + 1] = { name = test.name, status = M.STATUS.SKIPPED, duration = 0 } results.stats.skipped = results.stats.skipped + 1 elseif config.filter and not string.find(test.name, config.filter, 1, true) then results.tests[#results.tests + 1] = { name = test.name, status = M.STATUS.SKIPPED, duration = 0 } results.stats.skipped = results.stats.skipped + 1 else local testResult = M.runTest(test, hooks) results.tests[#results.tests + 1] = testResult if testResult.status == M.STATUS.PASSED then results.stats.passed = results.stats.passed + 1 elseif testResult.status == M.STATUS.FAILED then results.stats.failed = results.stats.failed + 1 if config.stopOnFirstFailure then break end elseif testResult.status == M.STATUS.SKIPPED then results.stats.skipped = results.stats.skipped + 1 end end results.stats.total = results.stats.total + 1 end for _, nestedSuite in ipairs(suite.nested) do local nestedResults = M.runSuite(nestedSuite, config, hooks) results.nested[#results.nested + 1] = nestedResults results.stats.total = results.stats.total + nestedResults.stats.total results.stats.passed = results.stats.passed + nestedResults.stats.passed results.stats.failed = results.stats.failed + nestedResults.stats.failed results.stats.skipped = results.stats.skipped + nestedResults.stats.skipped end if suite.afterAll then pcall(suite.afterAll) end results.stats.duration = (os.clock() - startTime) * 1000 return results end function M.runAll(suites, config) local allResults = { suites = {}, stats = { total = 0, passed = 0, failed = 0, skipped = 0, duration = 0, suiteCount = 0 }, timestamp = os.date("%Y-%m-%dT%H:%M:%S") } local startTime = os.clock() for _, suite in ipairs(suites) do local suiteResults = M.runSuite(suite, config) allResults.suites[#allResults.suites + 1] = suiteResults allResults.stats.suiteCount = allResults.stats.suiteCount + 1 allResults.stats.total = allResults.stats.total + suiteResults.stats.total allResults.stats.passed = allResults.stats.passed + suiteResults.stats.passed allResults.stats.failed = allResults.stats.failed + suiteResults.stats.failed allResults.stats.skipped = allResults.stats.skipped + suiteResults.stats.skipped end allResults.stats.duration = (os.clock() - startTime) * 1000 allResults.stats.success = allResults.stats.failed == 0 return allResults end return M ` } function getHelpersModule(): string { return ` local M = {} function M.generateTestData(template, count) local data = {} count = count or 10 for i = 1, count do local item = {} for k, v in pairs(template) do if type(v) == "function" then item[k] = v(i) elseif type(v) == "string" and v:match("^%$") then local varName = v:sub(2) if varName == "index" then item[k] = i elseif varName == "random" then item[k] = math.random(1, 1000) else item[k] = v end else item[k] = v end end data[#data + 1] = item end return data end M.table = {} function M.table.clone(t) if type(t) ~= "table" then return t end local copy = {} for k, v in pairs(t) do copy[k] = M.table.clone(v) end return setmetatable(copy, getmetatable(t)) end function M.table.merge(...) local result = {} for _, t in ipairs({...}) do if type(t) == "table" then for k, v in pairs(t) do result[k] = v end end end return result end function M.table.keys(t) local keys = {} for k in pairs(t) do keys[#keys + 1] = k end return keys end function M.table.values(t) local values = {} for _, v in pairs(t) do values[#values + 1] = v end return values end function M.table.size(t) local count = 0 for _ in pairs(t) do count = count + 1 end return count end M.string = {} function M.string.trim(s) return s:match("^%s*(.-)%s*$") end function M.string.split(s, delimiter) delimiter = delimiter or "%s" local result = {} for match in (s .. delimiter):gmatch("(.-)" .. delimiter) do result[#result + 1] = match end return result end function M.string.startsWith(s, prefix) return s:sub(1, #prefix) == prefix end function M.string.endsWith(s, suffix) return suffix == "" or s:sub(-#suffix) == suffix end return M ` } function getJsonParserModule(): string { return ` local M = {} function M.decode(str) local pos = 1 local function skip_whitespace() while pos <= #str and str:sub(pos, pos):match("%s") do pos = pos + 1 end end local function parse_string() pos = pos + 1 local start = pos while pos <= #str do local c = str:sub(pos, pos) if c == '"' then local result = str:sub(start, pos - 1) pos = pos + 1 result = result:gsub("\\\\n", "\\n"):gsub("\\\\t", "\\t"):gsub("\\\\r", "\\r") result = result:gsub('\\\\"', '"'):gsub("\\\\\\\\\\\\\\\\", "\\\\") return result elseif c == "\\\\" then pos = pos + 2 else pos = pos + 1 end end error("Unterminated string") end local function parse_number() local start = pos while pos <= #str and str:sub(pos, pos):match("[%d%.eE%+%-]") do pos = pos + 1 end return tonumber(str:sub(start, pos - 1)) end local parse_value local function parse_array() pos = pos + 1 local arr = {} skip_whitespace() if str:sub(pos, pos) == "]" then pos = pos + 1 return arr end while true do skip_whitespace() arr[#arr + 1] = parse_value() skip_whitespace() local c = str:sub(pos, pos) if c == "]" then pos = pos + 1 return arr elseif c == "," then pos = pos + 1 else error("Expected ',' or ']' at pos " .. pos) end end end local function parse_object() pos = pos + 1 local obj = {} skip_whitespace() if str:sub(pos, pos) == "}" then pos = pos + 1 return obj end while true do skip_whitespace() if str:sub(pos, pos) ~= '"' then error("Expected string key at pos " .. pos) end local key = parse_string() skip_whitespace() if str:sub(pos, pos) ~= ":" then error("Expected ':' at pos " .. pos) end pos = pos + 1 skip_whitespace() obj[key] = parse_value() skip_whitespace() local c = str:sub(pos, pos) if c == "}" then pos = pos + 1 return obj elseif c == "," then pos = pos + 1 else error("Expected ',' or '}' at pos " .. pos) end end end parse_value = function() skip_whitespace() local c = str:sub(pos, pos) if c == '"' then return parse_string() elseif c == "{" then return parse_object() elseif c == "[" then return parse_array() elseif c == "t" and str:sub(pos, pos + 3) == "true" then pos = pos + 4 return true elseif c == "f" and str:sub(pos, pos + 4) == "false" then pos = pos + 5 return false elseif c == "n" and str:sub(pos, pos + 3) == "null" then pos = pos + 4 return nil elseif c:match("[%d%-]") then return parse_number() else error("Unexpected char '" .. c .. "' at pos " .. pos) end end return parse_value() end return M ` }