feature_runner.lua
1 -- Minimal Gherkin runner: parses .feature files and runs registered step definitions. 2 -- Usage: lua tests/runner/feature_runner.lua [tests/features/*.feature] 3 -- Step defs are loaded from tests/step_definitions/*.lua (they register with Given/When/Then). 4 5 local M = {} 6 7 local STEP_KEYWORDS = { 'Given', 'When', 'Then', 'And' } 8 local step_registry = { Given = {}, When = {}, Then = {}, And = {} } 9 10 function M.register(kind, pattern, fn) 11 if not step_registry[kind] then step_registry[kind] = {} end 12 table.insert(step_registry[kind], { pattern = pattern, fn = fn }) 13 end 14 15 -- Parse step text to extract a Lua table if present (e.g. { "a.png", "b.png" } or { font_size = 12 }) 16 function M.parse_table_in_step(step_text) 17 local s, e = step_text:find('{.*}') 18 if not s then return nil end 19 local tbl_str = step_text:sub(s, e) 20 local fn, err = load('return ' .. tbl_str) 21 if not fn then return nil end 22 return fn() 23 end 24 25 -- Parse quoted string from step (e.g. Then key "K" ... -> "K") 26 function M.parse_quoted(step_text) 27 local q = step_text:match('"([^"]*)"') 28 if q then return q end 29 return step_text:match("'([^']*)'") 30 end 31 32 function M.parse_number(step_text) 33 return tonumber(step_text:match('%d+')) 34 end 35 36 -- Find step definition for step line (e.g. "Given backdrops with files { \"a.png\" }") 37 function M.find_step(kind, step_text) 38 local registry 39 if kind == 'And' then 40 registry = {} 41 for _, r in ipairs(step_registry.And or {}) do table.insert(registry, r) end 42 for _, r in ipairs(step_registry.Given or {}) do table.insert(registry, r) end 43 for _, r in ipairs(step_registry.When or {}) do table.insert(registry, r) end 44 for _, r in ipairs(step_registry.Then or {}) do table.insert(registry, r) end 45 else 46 registry = step_registry[kind] or {} 47 end 48 -- Prefer exact match, then prefix/substring match (longer patterns first to avoid "current index is 1" matching "current index is 3") 49 local exact_match 50 local prefix_matches = {} 51 for _, def in ipairs(registry) do 52 if type(def.pattern) == 'string' then 53 if step_text == def.pattern then exact_match = def.fn break end 54 if step_text:find(def.pattern, 1, true) then table.insert(prefix_matches, { len = #def.pattern, fn = def.fn }) end 55 elseif type(def.pattern) == 'function' and def.pattern(step_text) then 56 return def.fn 57 end 58 end 59 if exact_match then return exact_match end 60 table.sort(prefix_matches, function(a, b) return a.len > b.len end) 61 if prefix_matches[1] then return prefix_matches[1].fn end 62 return nil 63 end 64 65 -- Simple .feature parser: yield (feature_name, feature_description, scenario_name, steps[]) 66 -- feature_description is a table of trimmed lines (the paragraph under Feature:). 67 function M.parse_feature_file(path) 68 local content = io.open(path, 'r') 69 if not content then return nil, 'cannot open ' .. path end 70 content = content:read('*a') 71 local lines = {} 72 for line in content:gmatch('[^\r\n]+') do 73 table.insert(lines, line) 74 end 75 local feature_name, feature_description, scenario_name, steps, in_scenario 76 local function emit_scenario() 77 if scenario_name and #steps > 0 then 78 coroutine.yield(feature_name, feature_description or {}, scenario_name, steps) 79 end 80 end 81 return coroutine.wrap(function() 82 for _, line in ipairs(lines) do 83 local trimmed = line:match('^%s*(.-)%s*$') 84 if trimmed:match('^Feature:') then 85 emit_scenario() 86 feature_name = trimmed:gsub('^Feature:%s*', '') 87 feature_description = {} 88 steps = {} 89 in_scenario = false 90 elseif trimmed:match('^Scenario:') then 91 emit_scenario() 92 scenario_name = trimmed:gsub('^Scenario:%s*', '') 93 steps = {} 94 in_scenario = true 95 elseif in_scenario then 96 for _, kw in ipairs(STEP_KEYWORDS) do 97 local prefix = kw .. ' ' 98 if trimmed:sub(1, #prefix) == prefix then 99 table.insert(steps, { kind = kw, text = trimmed:sub(#prefix + 1):match('^%s*(.-)%s*$') }) 100 break 101 end 102 end 103 else 104 -- Description line (under Feature:, before first Scenario:) 105 if feature_name and trimmed ~= '' and not trimmed:match('^Scenario:') then 106 table.insert(feature_description, trimmed) 107 end 108 end 109 end 110 emit_scenario() 111 end) 112 end 113 114 -- Run one feature file; return pass count, fail count, errors 115 function M.run_feature_file(path, world, tap_output) 116 world = world or {} 117 tap_output = tap_output or false 118 local parser, err = M.parse_feature_file(path) 119 if not parser then return 0, 0, { err } end 120 local passed, failed = 0, 0 121 local errors = {} 122 local test_num = 0 123 for feature_name, _fd, scenario_name, steps in parser do 124 test_num = test_num + 1 125 local ok, err_msg = pcall(function() 126 local w = setmetatable({}, { __index = world }) 127 for _, step in ipairs(steps) do 128 local fn = M.find_step(step.kind, step.text) 129 if not fn then 130 error('Undefined step: ' .. step.kind .. ' ' .. step.text) 131 end 132 fn(w, step.text) 133 end 134 end) 135 if ok then 136 passed = passed + 1 137 if tap_output then 138 print(('ok %d - %s / %s'):format(test_num, feature_name, scenario_name)) 139 end 140 else 141 failed = failed + 1 142 table.insert(errors, path .. ': ' .. scenario_name .. ': ' .. tostring(err_msg)) 143 if tap_output then 144 print(('not ok %d - %s / %s # %s'):format(test_num, feature_name, scenario_name, tostring(err_msg):gsub('\n', ' '))) 145 end 146 end 147 end 148 return passed, failed, errors 149 end 150 151 -- Byfeature-style formatter: Feature:/Scenario: in bold white, steps with ✓ (green) / ✗ (red) / cyan (setup). 152 local function is_assertion_step(kind, in_then) 153 if kind == 'Then' then return true end 154 if kind == 'And' and in_then then return true end 155 return false 156 end 157 158 function M.run_feature_file_byfeature(path, world) 159 world = world or {} 160 local colors = require('tests.runner.colors') 161 local parser, err = M.parse_feature_file(path) 162 if not parser then 163 print(colors.red('Error: ' .. tostring(err))) 164 return 0, 0, { tostring(err) } 165 end 166 local passed, failed = 0, 0 167 local errors = {} 168 local last_feature_name 169 for feature_name, feature_description, scenario_name, steps in parser do 170 if last_feature_name ~= feature_name then 171 print('') 172 print(colors.bold_white('Feature: ' .. feature_name)) 173 for _, desc in ipairs(feature_description) do 174 local t = desc:match('^%s*(.-)%s*$') 175 if t ~= '' then print(' ' .. t) end 176 end 177 last_feature_name = feature_name 178 end 179 print('') 180 print(' ' .. colors.bold_white('Scenario') .. ': ' .. scenario_name) 181 local w = setmetatable({}, { __index = world }) 182 local scenario_ok = true 183 local in_then = false -- true after we've seen a Then (so And = assertion) 184 for _, step in ipairs(steps) do 185 local line = ' ' .. step.kind .. ' ' .. step.text 186 local fn = M.find_step(step.kind, step.text) 187 if not fn then 188 print(colors.red(line .. ' ✗')) 189 print(colors.red(' Error: Undefined step')) 190 scenario_ok = false 191 failed = failed + 1 192 table.insert(errors, path .. ': ' .. scenario_name .. ': Undefined step ' .. step.kind .. ' ' .. step.text) 193 break 194 end 195 local ok, err_msg = pcall(fn, w, step.text) 196 if not ok then 197 print(colors.red(line .. ' ✗')) 198 if err_msg and tostring(err_msg) ~= '' then 199 print(colors.red(' Error: ' .. tostring(err_msg):gsub('\n', ' '))) 200 end 201 scenario_ok = false 202 failed = failed + 1 203 table.insert(errors, path .. ': ' .. scenario_name .. ': ' .. tostring(err_msg)) 204 break 205 end 206 if is_assertion_step(step.kind, in_then) then 207 print(colors.green(line .. ' ✓')) 208 else 209 print(colors.cyan(line)) 210 end 211 if step.kind == 'Then' then in_then = true 212 elseif step.kind == 'Given' or step.kind == 'When' then in_then = false 213 end 214 end 215 if scenario_ok then passed = passed + 1 end 216 end 217 return passed, failed, errors 218 end 219 220 -- Run all .feature files in dir; return total passed, failed, errors 221 function M.run_features_dir(dir, tap_output) 222 dir = dir or (arg[0] and arg[0]:match('^(.+)/') or '.') .. '/../features' 223 if not dir:match('/$') then dir = dir .. '/' end 224 local passed, failed = 0, 0 225 local all_errors = {} 226 local lfs = require('lfs') 227 for name in lfs.dir(dir) do 228 if name:match('%.feature$') then 229 local p, f, errs = M.run_feature_file(dir .. name, {}, tap_output) 230 passed, failed = passed + p, failed + f 231 for _, e in ipairs(errs or {}) do table.insert(all_errors, e) end 232 end 233 end 234 return passed, failed, all_errors 235 end 236 237 return M