Files
2026-07-13 12:32:21 +08:00

489 lines
11 KiB
Lua

local say = require "say"
local luassert = require "luassert.assert"
local pretty = require "pl.pretty"
local fmt = string.format
local insert = table.insert
---@param v any
---@return string
local function pretty_print(v)
local s, err = pretty.write(v)
if not s then
s = "ERROR: failed to pretty-print value: " .. tostring(err)
end
return s
end
local E_ARG_COUNT = "assertion.internal.argtolittle"
local E_ARG_TYPE = "assertion.internal.badargtype"
---@alias spec.helpers.wait.ctx.result
---| "timeout"
---| "error"
---| "success"
---| "max tries"
local TIMEOUT = "timeout"
local ERROR = "error"
local SUCCESS = "success"
local MAX_TRIES = "max tries"
---@alias spec.helpers.wait.ctx.condition
---| "truthy"
---| "falsy"
---| "error"
---| "no_error"
--- helper functions that check the result of pcall() and report if the
--- wait ctx condition has been met
---
---@type table<spec.helpers.wait.ctx.condition, fun(boolean, any):boolean>
local COND = {
truthy = function(pok, ok_or_err)
return (pok and ok_or_err and true) or false
end,
falsy = function(pok, ok_or_err)
return (pok and not ok_or_err) or false
end,
error = function(pok)
return not pok
end,
no_error = function(pok)
return (pok and true) or false
end,
}
---@param ... any
---@return any
local function first_non_nil(...)
local n = select("#", ...)
for i = 1, n do
local v = select(i, ...)
if v ~= nil then
return v
end
end
end
---@param exp_type string
---@param field string|integer
---@param value any
---@param caller? string
---@param level? integer
---@return any
local function check_type(exp_type, field, value, caller, level)
caller = caller or "wait_until"
level = (level or 1) + 1
local got_type = type(value)
-- accept callable tables
if exp_type == "function"
and got_type == "table"
and type(debug.getmetatable(value)) == "table"
and type(debug.getmetatable(value).__call) == "function"
then
got_type = "function"
end
if got_type ~= exp_type then
error(say(E_ARG_TYPE, { field, caller, exp_type, type(value) }),
level)
end
return value
end
local DEFAULTS = {
timeout = 5,
step = 0.05,
message = "UNSPECIFIED",
max_tries = 0,
ignore_exceptions = false,
condition = "truthy",
}
---@class spec.helpers.wait.ctx
---
---@field condition "truthy"|"falsy"|"error"|"no_error"
---@field condition_met boolean
---@field debug? boolean
---@field elapsed number
---@field last_raised_error any
---@field error_raised boolean
---@field fn function
---@field ignore_exceptions boolean
---@field last_returned_error any
---@field last_returned_value any
---@field last_error any
---@field message? string
---@field result spec.helpers.wait.ctx.result
---@field step number
---@field timeout number
---@field traceback string|nil
---@field tries number
local wait_ctx = {
condition = nil,
condition_met = false,
debug = nil,
elapsed = 0,
error = nil,
error_raised = false,
ignore_exceptions = nil,
last_returned_error = nil,
last_returned_value = nil,
max_tries = nil,
message = nil,
result = "timeout",
step = nil,
timeout = nil,
traceback = nil,
tries = 0,
}
local wait_ctx_mt = { __index = wait_ctx }
function wait_ctx:dd(msg)
if self.debug then
print(fmt("\n\n%s\n\n", pretty_print(msg)))
end
end
function wait_ctx:wait()
ngx.update_time()
local tstart = ngx.now()
local texp = tstart + self.timeout
local ok, res, err
local is_met = COND[self.condition]
if self.condition == "no_error" then
self.ignore_exceptions = true
end
local tries_remain = self.max_tries
local f = self.fn
local handle_error = function(e)
self.traceback = debug.traceback("", 2)
return e
end
while true do
ok, res, err = xpcall(f, handle_error)
if ok then
self.last_returned_value = first_non_nil(res, self.last_returned_value)
self.last_returned_error = first_non_nil(err, self.last_returned_error)
self.last_error = first_non_nil(err, self.last_error)
else
self.error_raised = true
self.last_raised_error = first_non_nil(res, self.last_raised_error)
self.last_error = first_non_nil(res, self.last_error)
end
self.tries = self.tries + 1
tries_remain = tries_remain - 1
self.condition_met = is_met(ok, res)
self:dd(self)
ngx.update_time()
-- yay!
if self.condition_met then
self.result = SUCCESS
break
elseif self.error_raised and not self.ignore_exceptions then
self.result = ERROR
break
elseif tries_remain == 0 then
self.result = MAX_TRIES
break
elseif ngx.now() >= texp then
self.result = TIMEOUT
break
end
ngx.sleep(self.step)
end
ngx.update_time()
self.elapsed = ngx.now() - tstart
self:dd(self)
end
local CTX_TYPES = {
condition = "string",
fn = "function",
max_tries = "number",
timeout = "number",
message = "string",
step = "number",
ignore_exceptions = "boolean",
}
function wait_ctx:validate(key, value, caller, level)
local typ = CTX_TYPES[key]
if not typ then
-- we don't care about validating this key
return value
end
if key == "condition" and type(value) == "string" then
assert(COND[value] ~= nil,
say(E_ARG_TYPE, { "condition", caller or "wait_until",
"one of: 'truthy', 'falsy', 'error', 'no_error'",
value }), level + 1)
end
return check_type(typ, key, value, caller, level)
end
---@param state table
---@return spec.helpers.wait.ctx
local function get_or_create_ctx(state)
local ctx = rawget(state, "wait_ctx")
if not ctx then
ctx = setmetatable({}, wait_ctx_mt)
rawset(state, "wait_ctx", ctx)
end
return ctx
end
---@param ctx spec.helpers.wait.ctx
---@param key string
---@param ... any
local function param(ctx, key, ...)
local value = first_non_nil(first_non_nil(...), DEFAULTS[key])
ctx[key] = ctx:validate(key, value, "wait_until", 3)
end
---@param state table
---@param arguments table
---@param level integer
---@return boolean ok
---@return table return_values
local function wait_until(state, arguments, level)
assert(arguments.n > 0,
say(E_ARG_COUNT, { "wait_until", 1, arguments.n }),
level + 1)
local input = check_type("table", 1, arguments[1])
local ctx = get_or_create_ctx(state)
param(ctx, "fn", input.fn)
param(ctx, "timeout", input.timeout)
param(ctx, "step", input.step)
param(ctx, "message", input.message, arguments[2])
param(ctx, "max_tries", input.max_tries)
param(ctx, "debug", input.debug, ctx.debug, false)
param(ctx, "condition", input.condition)
param(ctx, "ignore_exceptions", input.ignore_exceptions)
-- reset the state
rawset(state, "wait_ctx", nil)
ctx:wait()
if ctx.condition_met then
return true, { ctx.last_returned_value, n = 1 }
end
local errors = {}
local result
if ctx.result == ERROR then
result = "error() raised"
elseif ctx.result == MAX_TRIES then
result = ("max tries (%s) reached"):format(ctx.max_tries)
elseif ctx.result == TIMEOUT then
result = ("timed out after %ss"):format(ctx.elapsed)
end
if ctx.last_returned_value ~= nil then
insert(errors, "Last returned value:")
insert(errors, "")
insert(errors, pretty_print(ctx.last_returned_value))
insert(errors, "")
end
if ctx.last_raised_error ~= nil then
insert(errors, "Last raised error:")
insert(errors, "")
insert(errors, pretty_print(ctx.last_raised_error))
insert(errors, "")
if ctx.traceback then
insert(errors, ctx.traceback)
insert(errors, "")
end
end
if ctx.last_returned_error ~= nil then
insert(errors, "Last returned error:")
insert(errors, "")
insert(errors, pretty_print(ctx.last_returned_error))
insert(errors, "")
end
arguments[1] = ctx.message
arguments[2] = result
arguments[3] = table.concat(errors, "\n")
arguments[4] = ctx.timeout
arguments[5] = ctx.step
arguments[6] = ctx.elapsed
arguments[7] = ctx.tries
arguments[8] = ctx.error_raised
arguments.n = 8
arguments.nofmt = {}
for i = 1, arguments.n do
arguments.nofmt[i] = true
end
return false, { ctx.last_error, n = 1 }
end
say:set("assertion.wait_until.failed", [[
Failed to assert eventual condition:
%q
Result: %s
%s
---
Timeout = %s
Step = %s
Elapsed = %s
Tries = %s
Raised = %s
]])
luassert:register("assertion", "wait_until", wait_until,
"assertion.wait_until.failed")
local function wait_until_modifier(key)
return function(state, arguments)
local ctx = get_or_create_ctx(state)
ctx[key] = ctx:validate(key, arguments[1], key, 1)
return state
end
end
luassert:register("modifier", "with_timeout",
wait_until_modifier("timeout"))
luassert:register("modifier", "with_step",
wait_until_modifier("step"))
luassert:register("modifier", "with_max_tries",
wait_until_modifier("max_tries"))
-- luassert blows up on us if we try to use 'error' or 'errors'
luassert:register("modifier", "ignore_exceptions",
wait_until_modifier("ignore_exceptions"))
luassert:register("modifier", "with_debug",
wait_until_modifier("debug"))
---@param ctx spec.helpers.wait.ctx
local function ctx_builder(ctx)
local self = setmetatable({}, {
__index = function(_, key)
error("unknown modifier/assertion: " .. tostring(key), 2)
end
})
local function with(field)
return function(value)
ctx[field] = ctx:validate(field, value, "with_" .. field, 2)
return self
end
end
self.with_timeout = with("timeout")
self.with_step = with("step")
self.with_max_tries = with("max_tries")
self.with_debug = with("debug")
self.ignore_exceptions = function(ignore)
ctx.ignore_exceptions = ctx:validate("ignore_exceptions", ignore,
"ignore_exceptions", 2)
return self
end
self.is_truthy = function(msg)
ctx.condition = "truthy"
return luassert.wait_until(ctx, msg)
end
self.is_falsy = function(msg)
ctx.condition = "falsy"
return luassert.wait_until(ctx, msg)
end
self.has_error = function(msg)
ctx.condition = "error"
return luassert.wait_until(ctx, msg)
end
self.has_no_error = function(msg)
ctx.condition = "no_error"
return luassert.wait_until(ctx, msg)
end
return self
end
local function eventually(state, arguments)
local ctx = get_or_create_ctx(state)
ctx.fn = first_non_nil(arguments[1], ctx.fn)
check_type("function", 1, ctx.fn, "eventually")
arguments[1] = ctx_builder(ctx)
arguments.n = 1
return true, arguments
end
luassert:register("assertion", "eventually", eventually)