/ tests / runner / feature_runner.lua
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