Files
metabuilder/packages/lua_test/src/lua-test-runner.ts

590 lines
19 KiB
TypeScript

/**
* Lua Test Runner Integration
* TypeScript utilities for running Lua tests from packages
*/
import type { JsonValue } from '@/types/utility-types'
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
}
/**
* Generates Lua code that runs tests and returns results
* This code uses the lua_test framework modules
*/
export function generateTestRunnerCode(
testCode: string,
config: LuaTestConfig = {}
): string {
const configJson = JSON.stringify(config)
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)()
-- 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
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
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
`
}