diff --git a/.agents/plans/A39-require-leaks-open-upvalues.md b/.agents/plans/A39-require-leaks-open-upvalues.md new file mode 100644 index 0000000..aef0505 --- /dev/null +++ b/.agents/plans/A39-require-leaks-open-upvalues.md @@ -0,0 +1,185 @@ +--- +id: A39 +title: require() leaks inner module's open_upvalues into outer caller +issue: 244 +pr: 245 +branch: fix/require-leaks-open-upvalues +base: main +status: review +direction: A +unlocks: + - luassert.assertions + - luassert.array + - luassert.spy +--- + +## Goal + +Fix `Lua.VM.Executor.execute/5` so that nested executions (most importantly +`require`) no longer leak the inner module's `state.open_upvalues` map +back to the outer caller. This unblocks loading real-world Lua libraries +that mix nested `require` chains with many top-level `local function` +definitions (luassert, busted, etc.). + +## Out of scope + +- Refactoring the upvalue / closure model. The bug is one missed + save/restore, not a design flaw. +- Adding a "close all open upvalues at chunk end" sweep. Not needed once + save/restore is in place. +- The full `package.searchers` mechanism. Unrelated. +- Bytecode-encoder support for vararg chunks (chunks falling back to the + interpreter is what surfaces this bug, but the interpreter path should + be correct on its own). +- Coordinating the `tv-labs/platform/sidecar` Lua bump. Mentioned in the + issue but a downstream concern. + +## Success criteria + +- [ ] `mix test` passes with no regressions vs. baseline (1772 tests). +- [ ] New unit regression in `test/lua/vm/require_open_upvalue_test.exs` + reproduces the bug with a minimal two-file pure-Lua repro and + asserts the outer's local reads correctly. +- [ ] New integration test under `test/integration/luassert_test.exs` + vendors a real subset of `luassert` + `say` and asserts that every + luassert module loads successfully via `require`. +- [ ] `mix test --only lua53` suite count does not regress. + +## Implementation notes + +### Root cause + +`Lua.VM.Executor.execute/5` at `lib/lua/vm/executor.ex:73-82` resets +`state.open_upvalues` to `%{}` at entry but never restores the caller's +`open_upvalues` on return. Every other call site that descends into a +nested execution (`call_function/3` for `:lua_closure`, `call_value/5`, +the dispatcher entry, the dispatcher's frame returns, the interpreter's +`:call` op for Lua closures) carefully saves the caller's map, resets +to `%{}`, runs the callee, and restores on return. `Executor.execute/5` +is the one outlier. + +When `require` is called as a `native_func` from a Lua execution, the +inner module's `Lua.VM.execute(proto, state)` populates its own +`open_upvalues` as closures are created over the inner module's +top-level locals. When the inner returns, those entries leak back to the +outer caller. If the outer then creates a closure that captures a +top-level local at a register number that collides with one of the +inner's leftover entries, the outer's closure **reuses the inner's +stale cell**, aliasing the outer's local to whatever value the inner had +at that register. + +For `luassert.assertions`, the outer's `assert` (reg 0) ends up aliased +to the inner `luassert.assert` module's `s` (reg 0, the `say` module). +At line 307, `assert:register(...)` reads `assert` through the stale +upvalue cell and sees `say`, not the obj table — hence +"attempt to call a nil value (method 'register' on local 'assert')". + +### Fix + +In `lib/lua/vm/executor.ex` `execute/5`, snapshot `state.open_upvalues` +before resetting and restore it on the way out: + +```elixir +def execute(instructions, registers, upvalues, proto, state) do + prev = Process.get(@position_key, @unset) + saved_open_upvalues = state.open_upvalues + + try do + state = %{state | open_upvalues: %{}} + + {results, regs, state} = + do_execute(instructions, registers, upvalues, proto, state, [], [], 0) + + {results, regs, %{state | open_upvalues: saved_open_upvalues}} + after + restore_position(prev) + end +end +``` + +Two callers of `Executor.execute/5`: + +- `Lua.VM.execute/2` (`lib/lua/vm.ex:26`) — used by + `parse_and_execute_module` (the bug path) and by top-level + `Lua.eval!`. Save/restore is correct in both cases. +- `Lua.do_call_function/3` for `:lua_closure` (`lib/lua.ex:717`) — + called from the public `Lua.call_function/3`. Save/restore makes the + public API safer: callers don't lose `open_upvalues` across + `call_function` invocations. + +### Tests + +Two layers: + +1. **Unit regression** (`test/lua/vm/require_open_upvalue_test.exs`). + Minimal two-file pure-Lua repro: inner module declares a top-level + local at reg 0 and creates a closure capturing it; outer module + requires inner, then declares its own top-level local at reg 0 and + creates a closure capturing it. Assert the outer's local reads the + correct value, not the inner's leaked value. + +2. **Luassert integration** (`test/integration/luassert/` + + `test/integration/luassert_test.exs`). Vendor the luassert v1.9.0 + + say v1.4.1 source files under `test/integration/luassert/lua/`. + Assert that `require('luassert')` and every interior luassert module + load without error. Behavioural assertions (e.g. + `assert.are.equal(1, 1)` returns truthy) are deferred to a follow-up + plan; this PR proves the *loading* pipeline works. + +Vendor with upstream LICENSE files. Document the pin and source in +`test/integration/luassert/README.md`. + +## Verification + +```bash +mix format +mix compile --warnings-as-errors +mix test +mix test test/lua/vm/require_open_upvalue_test.exs +mix test test/integration/luassert_test.exs +mix test --only lua53 +``` + +Suite count before this plan: 1772 passing, 0 failing, 30 skipped. + +## Risks + +- **The fix changes observable state after a top-level `Lua.eval!`.** + Specifically, `state.open_upvalues` after an eval will now be the + pre-eval value (typically `%{}`) instead of whatever the chunk left + open. Mitigated by the full test run; `open_upvalues` is internal + state with no documented public consumers. +- **`Lua.call_function/3` (public API) starts preserving the caller's + `open_upvalues`.** This is a behavior change, but the previous + behavior was the bug. Documented in `CHANGELOG.md`. +- **Vendored luassert may shift if upstream changes.** Pinned to a + specific tag; future updates are explicit PRs. + +## What changed + +- **`lib/lua/vm/executor.ex`** — `execute/5` now snapshots + `state.open_upvalues` before resetting it to `%{}` and restores the + snapshot on return. Matches the save/restore pattern already used by + `call_function/3` for `:lua_closure`, `call_value/5`, and the + dispatcher entry. +- **`test/lua/vm/require_open_upvalue_test.exs`** — new unit + regression. Two pure-Lua repros: (1) minimal inner-closure-shadows- + outer-local at reg 0 case; (2) the luassert-shape "many local + function defs between require and method call" that surfaces the + exact `attempt to call a nil value (method 'register' on local + 'assert')` from #244. +- **`test/integration/luassert/`** — vendored luassert v1.9.0 + say + v1.4.1 under `lua/`, with upstream LICENSE files alongside. README + documents the pin and update procedure. +- **`test/integration/luassert_test.exs`** — 18 tests, no opt-in tag + so they run on every `mix test` (including CI). Without the fix, 8 + of 18 fail (exactly the modules called out in #244 plus their API + smoke tests). +- **`CHANGELOG.md`** — Unreleased / Fixed entry for #244 with the + side-effect note about `Lua.call_function/3`. + +### Suite delta + +- `mix test`: 1772 → 1792 (+20 new tests, 0 failures). +- `mix test --only lua53`: 29 tests / 0 failures / 23 skipped + (unchanged). diff --git a/CHANGELOG.md b/CHANGELOG.md index f00e599..b6ef00a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed +- `require` no longer leaks the loaded module's `open_upvalues` map back + to the calling chunk. Loading a module whose body created closures over + its own top-level locals could alias the caller's locals to stale inner + upvalue cells, breaking real-world libraries (e.g. `luassert.assertions`, + `luassert.array`, `luassert.spy`) that follow the pattern + `local x = require(...)` → many `local function` defs → `x:method(...)`. + As a side effect, `Lua.call_function/3` (public API) now preserves the + caller's `open_upvalues` across calls (#244). + ## [v1.0.0-rc.0] - 2026-05-26 This is the first release candidate for `1.0.0`. The library has been diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index 1a5369a..e4aba7a 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -67,15 +67,28 @@ defmodule Lua.VM.Executor do Saves and restores any prior `current_position/0` snapshot so nested executions (e.g. an Elixir callback that itself calls `Lua.eval!`) don't leak source positions into each other. + + Likewise saves and restores `state.open_upvalues` so that a nested + execution's upvalue cells — keyed by register index — cannot collide + with the caller's. Without this, a `require` that runs a module body + containing closures over its top-level locals would leak those cells + back to the caller; the caller's later closures would then reuse the + stale cells by register index, aliasing the caller's locals to + unrelated inner values. """ @spec execute([tuple()], tuple(), list(), map(), State.t()) :: {list(), tuple(), State.t()} def execute(instructions, registers, upvalues, proto, state) do prev = Process.get(@position_key, @unset) + saved_open_upvalues = state.open_upvalues try do state = %{state | open_upvalues: %{}} - do_execute(instructions, registers, upvalues, proto, state, [], [], 0) + + {results, regs, state} = + do_execute(instructions, registers, upvalues, proto, state, [], [], 0) + + {results, regs, %{state | open_upvalues: saved_open_upvalues}} after restore_position(prev) end diff --git a/test/integration/luassert/README.md b/test/integration/luassert/README.md new file mode 100644 index 0000000..172e5a4 --- /dev/null +++ b/test/integration/luassert/README.md @@ -0,0 +1,86 @@ +# Vendored luassert integration test + +This directory contains vendored Lua source from two upstream libraries, +used as end-to-end regression coverage for the `require` pipeline. + +## Pinned versions + +| Library | Tag | Upstream | +| -------- | ------- | ------------------------------------------------------- | +| luassert | v1.9.0 | https://github.com/lunarmodules/luassert/tree/v1.9.0 | +| say | v1.4.1 | https://github.com/lunarmodules/say/tree/v1.4.1 | + +## Why these libraries + +luassert is the assertion library used by [busted](https://lunarmodules.github.io/busted/), +the dominant testing framework in the Lua ecosystem. It exercises: + +- Multi-level `require` chains. +- Modules with 50+ top-level `local function` definitions that close + over the module's own top-level locals. +- Modules that return tables and modules that only register side + effects and return nothing. +- `setmetatable`, `__call`, `__index` metamethods on returned values. + +`say` is luassert's i18n dependency. Both are pure Lua, no C bindings. + +The shape of `luassert/assertions.lua` — `local assert = require('luassert.assert')`, +followed by many `local function` definitions, followed by +`assert:register(...)` — is what surfaced the bug fixed in +[#244](https://github.com/tv-labs/lua/issues/244). + +## Layout + +``` +lua/ +├── luassert/ +│ ├── LICENSE ← upstream MIT license +│ ├── init.lua ← top-level entrypoint +│ ├── assert.lua ← core obj/metatable +│ ├── assertions.lua ← built-in assertions (the bug's epicenter) +│ ├── modifiers.lua +│ ├── array.lua +│ ├── spy.lua / stub.lua / mock.lua +│ ├── match.lua +│ ├── state.lua / util.lua / namespaces.lua / compatibility.lua +│ ├── formatters/ +│ ├── matchers/ +│ └── languages/ +└── say/ + ├── LICENSE ← upstream MIT license + └── init.lua ← i18n string lookup +``` + +The `lua/` prefix matches the conventional `package.path` of +`?.lua;?/init.lua`, so `require('luassert')` resolves to +`lua/luassert/init.lua` and `require('luassert.assert')` resolves to +`lua/luassert/assert.lua`. + +## Updating the pin + +``` +# From repo root. +cd /tmp +rm -rf luassert-* say-* +curl -sL -o luassert.tar.gz \ + https://github.com/lunarmodules/luassert/archive/refs/tags/vX.Y.Z.tar.gz +curl -sL -o say.tar.gz \ + https://github.com/lunarmodules/say/archive/refs/tags/vA.B.C.tar.gz +tar -xzf luassert.tar.gz && tar -xzf say.tar.gz + +cd /test/integration/luassert/lua +rm -rf luassert/ say/ +mkdir -p luassert say +cp -r /tmp/luassert-X.Y.Z/src/* luassert/ +cp -r /tmp/say-A.B.C/src/say/init.lua say/ +cp /tmp/luassert-X.Y.Z/LICENSE luassert/LICENSE +cp /tmp/say-A.B.C/LICENSE say/LICENSE +``` + +Then update the version table above and re-run +`mix test test/integration/luassert_test.exs`. + +## License + +Both libraries are MIT-licensed. The upstream `LICENSE` files are +preserved alongside the vendored source. diff --git a/test/integration/luassert/lua/luassert/LICENSE b/test/integration/luassert/lua/luassert/LICENSE new file mode 100644 index 0000000..47cc42a --- /dev/null +++ b/test/integration/luassert/lua/luassert/LICENSE @@ -0,0 +1,22 @@ +MIT License Terms +================= + +Copyright (c) 2012 Olivine Labs, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/test/integration/luassert/lua/luassert/array.lua b/test/integration/luassert/lua/luassert/array.lua new file mode 100644 index 0000000..f9cb655 --- /dev/null +++ b/test/integration/luassert/lua/luassert/array.lua @@ -0,0 +1,70 @@ +local assert = require('luassert.assert') +local say = require('say') + +-- Example usage: +-- local arr = { "one", "two", "three" } +-- +-- assert.array(arr).has.no.holes() -- checks the array to not contain holes --> passes +-- assert.array(arr).has.no.holes(4) -- sets explicit length to 4 --> fails +-- +-- local first_hole = assert.array(arr).has.holes(4) -- check array of size 4 to contain holes --> passes +-- assert.equal(4, first_hole) -- passes, as the index of the first hole is returned + + +-- Unique key to store the object we operate on in the state object +-- key must be unique, to make sure we do not have name collissions in the shared state object +local ARRAY_STATE_KEY = "__array_state" + +-- The modifier, to store the object in our state +local function array(state, args, level) + assert(args.n > 0, "No array provided to the array-modifier") + assert(rawget(state, ARRAY_STATE_KEY) == nil, "Array already set") + rawset(state, ARRAY_STATE_KEY, args[1]) + return state +end + +-- The actual assertion that operates on our object, stored via the modifier +local function holes(state, args, level) + local length = args[1] + local arr = rawget(state, ARRAY_STATE_KEY) -- retrieve previously set object + -- only check against nil, metatable types are allowed + assert(arr ~= nil, "No array set, please use the array modifier to set the array to validate") + if length == nil then + length = 0 + for i in pairs(arr) do + if type(i) == "number" and + i > length and + math.floor(i) == i then + length = i + end + end + end + assert(type(length) == "number", "expected array length to be of type 'number', got: "..tostring(length)) + -- let's do the actual assertion + local missing + for i = 1, length do + if arr[i] == nil then + missing = i + break + end + end + -- format arguments for output strings; + args[1] = missing + args.n = missing and 1 or 0 + return missing ~= nil, { missing } -- assert result + first missing index as return value +end + +-- Register the proper assertion messages +say:set("assertion.array_holes.positive", [[ +Expected array to have holes, but none was found. +]]) +say:set("assertion.array_holes.negative", [[ +Expected array to not have holes, hole found at position: %s +]]) + +-- Register the assertion, and the modifier +assert:register("assertion", "holes", holes, + "assertion.array_holes.positive", + "assertion.array_holes.negative") + +assert:register("modifier", "array", array) diff --git a/test/integration/luassert/lua/luassert/assert.lua b/test/integration/luassert/lua/luassert/assert.lua new file mode 100644 index 0000000..7fe7569 --- /dev/null +++ b/test/integration/luassert/lua/luassert/assert.lua @@ -0,0 +1,180 @@ +local s = require 'say' +local astate = require 'luassert.state' +local util = require 'luassert.util' +local unpack = util.unpack +local obj -- the returned module table +local level_mt = {} + +-- list of namespaces +local namespace = require 'luassert.namespaces' + +local function geterror(assertion_message, failure_message, args) + if util.hastostring(failure_message) then + failure_message = tostring(failure_message) + elseif failure_message ~= nil then + failure_message = astate.format_argument(failure_message) + end + local message = s(assertion_message, obj:format(args)) + if message and failure_message then + message = failure_message .. "\n" .. message + end + return message or failure_message +end + +local __state_meta = { + + __call = function(self, ...) + local keys = util.extract_keys("assertion", self.tokens) + + local assertion + + for _, key in ipairs(keys) do + assertion = namespace.assertion[key] or assertion + end + + if assertion then + for _, key in ipairs(keys) do + if namespace.modifier[key] then + namespace.modifier[key].callback(self) + end + end + + local arguments = util.make_arglist(...) + local val, retargs = assertion.callback(self, arguments, util.errorlevel()) + + if (not val) == self.mod then + local message = assertion.positive_message + if not self.mod then + message = assertion.negative_message + end + local err = geterror(message, rawget(self,"failure_message"), arguments) + error(err or "assertion failed!", util.errorlevel()) + end + + if retargs then + return unpack(retargs) + end + return ... + else + local arguments = util.make_arglist(...) + self.tokens = {} + + for _, key in ipairs(keys) do + if namespace.modifier[key] then + namespace.modifier[key].callback(self, arguments, util.errorlevel()) + end + end + end + + return self + end, + + __index = function(self, key) + for token in key:lower():gmatch('[^_]+') do + table.insert(self.tokens, token) + end + + return self + end +} + +obj = { + state = function() return setmetatable({mod=true, tokens={}}, __state_meta) end, + + -- registers a function in namespace + register = function(self, nspace, name, callback, positive_message, negative_message) + local lowername = name:lower() + if not namespace[nspace] then + namespace[nspace] = {} + end + namespace[nspace][lowername] = { + callback = callback, + name = lowername, + positive_message=positive_message, + negative_message=negative_message + } + end, + + -- unregisters a function in a namespace + unregister = function(self, nspace, name) + local lowername = name:lower() + if not namespace[nspace] then + namespace[nspace] = {} + end + namespace[nspace][lowername] = nil + end, + + -- registers a formatter + -- a formatter takes a single argument, and converts it to a string, or returns nil if it cannot format the argument + add_formatter = function(self, callback) + astate.add_formatter(callback) + end, + + -- unregisters a formatter + remove_formatter = function(self, fmtr) + astate.remove_formatter(fmtr) + end, + + format = function(self, args) + -- args.n specifies the number of arguments in case of 'trailing nil' arguments which get lost + local nofmt = args.nofmt or {} -- arguments in this list should not be formatted + local fmtargs = args.fmtargs or {} -- additional arguments to be passed to formatter + for i = 1, (args.n or #args) do -- cannot use pairs because table might have nils + if not nofmt[i] then + local val = args[i] + local valfmt = astate.format_argument(val, nil, fmtargs[i]) + if valfmt == nil then valfmt = tostring(val) end -- no formatter found + args[i] = valfmt + end + end + return args + end, + + set_parameter = function(self, name, value) + astate.set_parameter(name, value) + end, + + get_parameter = function(self, name) + return astate.get_parameter(name) + end, + + add_spy = function(self, spy) + astate.add_spy(spy) + end, + + snapshot = function(self) + return astate.snapshot() + end, + + level = function(self, level) + return setmetatable({ + level = level + }, level_mt) + end, + + -- returns the level if a level-value, otherwise nil + get_level = function(self, level) + if getmetatable(level) ~= level_mt then + return nil -- not a valid error-level + end + return level.level + end, +} + +local __meta = { + + __call = function(self, bool, message, level, ...) + if not bool then + local err_level = (self:get_level(level) or 1) + 1 + error(message or "assertion failed!", err_level) + end + return bool , message , level , ... + end, + + __index = function(self, key) + return rawget(self, key) or self.state()[key] + end, + +} + +return setmetatable(obj, __meta) diff --git a/test/integration/luassert/lua/luassert/assertions.lua b/test/integration/luassert/lua/luassert/assertions.lua new file mode 100644 index 0000000..e5083c3 --- /dev/null +++ b/test/integration/luassert/lua/luassert/assertions.lua @@ -0,0 +1,334 @@ +-- module will not return anything, only register assertions with the main assert engine + +-- assertions take 2 parameters; +-- 1) state +-- 2) arguments list. The list has a member 'n' with the argument count to check for trailing nils +-- 3) level The level of the error position relative to the called function +-- returns; boolean; whether assertion passed + +local assert = require('luassert.assert') +local astate = require ('luassert.state') +local util = require ('luassert.util') +local s = require('say') + +local function format(val) + return astate.format_argument(val) or tostring(val) +end + +local function set_failure_message(state, message) + if message ~= nil then + state.failure_message = message + end +end + +local function unique(state, arguments, level) + local list = arguments[1] + local deep + local argcnt = arguments.n + if type(arguments[2]) == "boolean" or (arguments[2] == nil and argcnt > 2) then + deep = arguments[2] + set_failure_message(state, arguments[3]) + else + if type(arguments[3]) == "boolean" then + deep = arguments[3] + end + set_failure_message(state, arguments[2]) + end + for k,v in pairs(list) do + for k2, v2 in pairs(list) do + if k ~= k2 then + if deep and util.deepcompare(v, v2, true) then + return false + else + if v == v2 then + return false + end + end + end + end + end + return true +end + +local function near(state, arguments, level) + local level = (level or 1) + 1 + local argcnt = arguments.n + assert(argcnt > 2, s("assertion.internal.argtolittle", { "near", 3, tostring(argcnt) }), level) + local expected = tonumber(arguments[1]) + local actual = tonumber(arguments[2]) + local tolerance = tonumber(arguments[3]) + local numbertype = "number or object convertible to a number" + assert(expected, s("assertion.internal.badargtype", { 1, "near", numbertype, format(arguments[1]) }), level) + assert(actual, s("assertion.internal.badargtype", { 2, "near", numbertype, format(arguments[2]) }), level) + assert(tolerance, s("assertion.internal.badargtype", { 3, "near", numbertype, format(arguments[3]) }), level) + -- switch arguments for proper output message + util.tinsert(arguments, 1, util.tremove(arguments, 2)) + arguments[3] = tolerance + arguments.nofmt = arguments.nofmt or {} + arguments.nofmt[3] = true + set_failure_message(state, arguments[4]) + return (actual >= expected - tolerance and actual <= expected + tolerance) +end + +local function matches(state, arguments, level) + local level = (level or 1) + 1 + local argcnt = arguments.n + assert(argcnt > 1, s("assertion.internal.argtolittle", { "matches", 2, tostring(argcnt) }), level) + local pattern = arguments[1] + local actual = nil + if util.hastostring(arguments[2]) or type(arguments[2]) == "number" then + actual = tostring(arguments[2]) + end + local err_message + local init_arg_num = 3 + for i=3,argcnt,1 do + if arguments[i] and type(arguments[i]) ~= "boolean" and not tonumber(arguments[i]) then + if i == 3 then init_arg_num = init_arg_num + 1 end + err_message = util.tremove(arguments, i) + break + end + end + local init = arguments[3] + local plain = arguments[4] + local stringtype = "string or object convertible to a string" + assert(type(pattern) == "string", s("assertion.internal.badargtype", { 1, "matches", "string", type(arguments[1]) }), level) + assert(actual, s("assertion.internal.badargtype", { 2, "matches", stringtype, format(arguments[2]) }), level) + assert(init == nil or tonumber(init), s("assertion.internal.badargtype", { init_arg_num, "matches", "number", type(arguments[3]) }), level) + -- switch arguments for proper output message + util.tinsert(arguments, 1, util.tremove(arguments, 2)) + set_failure_message(state, err_message) + local retargs + local ok + if plain then + ok = (actual:find(pattern, init, plain) ~= nil) + retargs = ok and { pattern } or {} + else + retargs = { actual:match(pattern, init) } + ok = (retargs[1] ~= nil) + end + return ok, retargs +end + +local function equals(state, arguments, level) + local level = (level or 1) + 1 + local argcnt = arguments.n + assert(argcnt > 1, s("assertion.internal.argtolittle", { "equals", 2, tostring(argcnt) }), level) + local result = arguments[1] == arguments[2] + -- switch arguments for proper output message + util.tinsert(arguments, 1, util.tremove(arguments, 2)) + set_failure_message(state, arguments[3]) + return result +end + +local function same(state, arguments, level) + local level = (level or 1) + 1 + local argcnt = arguments.n + assert(argcnt > 1, s("assertion.internal.argtolittle", { "same", 2, tostring(argcnt) }), level) + if type(arguments[1]) == 'table' and type(arguments[2]) == 'table' then + local result, crumbs = util.deepcompare(arguments[1], arguments[2], true) + -- switch arguments for proper output message + util.tinsert(arguments, 1, util.tremove(arguments, 2)) + arguments.fmtargs = arguments.fmtargs or {} + arguments.fmtargs[1] = { crumbs = crumbs } + arguments.fmtargs[2] = { crumbs = crumbs } + set_failure_message(state, arguments[3]) + return result + end + local result = arguments[1] == arguments[2] + -- switch arguments for proper output message + util.tinsert(arguments, 1, util.tremove(arguments, 2)) + set_failure_message(state, arguments[3]) + return result +end + +local function truthy(state, arguments, level) + local argcnt = arguments.n + assert(argcnt > 0, s("assertion.internal.argtolittle", { "truthy", 1, tostring(argcnt) }), level) + set_failure_message(state, arguments[2]) + return arguments[1] ~= false and arguments[1] ~= nil +end + +local function falsy(state, arguments, level) + local argcnt = arguments.n + assert(argcnt > 0, s("assertion.internal.argtolittle", { "falsy", 1, tostring(argcnt) }), level) + return not truthy(state, arguments, level) +end + +local function has_error(state, arguments, level) + local level = (level or 1) + 1 + local retargs = util.shallowcopy(arguments) + local func = arguments[1] + local err_expected = arguments[2] + local failure_message = arguments[3] + assert(util.callable(func), s("assertion.internal.badargtype", { 1, "error", "function or callable object", type(func) }), level) + local ok, err_actual = pcall(func) + if type(err_actual) == 'string' then + -- remove 'path/to/file:line: ' from string + err_actual = err_actual:gsub('^.-:%d+: ', '', 1) + end + retargs[1] = err_actual + arguments.nofmt = {} + arguments.n = 2 + arguments[1] = (ok and '(no error)' or err_actual) + arguments[2] = (err_expected == nil and '(error)' or err_expected) + arguments.nofmt[1] = ok + arguments.nofmt[2] = (err_expected == nil) + set_failure_message(state, failure_message) + + if ok or err_expected == nil then + return not ok, retargs + end + if type(err_expected) == 'string' then + -- err_actual must be (convertible to) a string + if util.hastostring(err_actual) then + err_actual = tostring(err_actual) + retargs[1] = err_actual + end + if type(err_actual) == 'string' then + return err_expected == err_actual, retargs + end + elseif type(err_expected) == 'number' then + if type(err_actual) == 'string' then + return tostring(err_expected) == tostring(tonumber(err_actual)), retargs + end + end + return same(state, {err_expected, err_actual, ["n"] = 2}), retargs +end + +local function error_matches(state, arguments, level) + local level = (level or 1) + 1 + local retargs = util.shallowcopy(arguments) + local argcnt = arguments.n + local func = arguments[1] + local pattern = arguments[2] + assert(argcnt > 1, s("assertion.internal.argtolittle", { "error_matches", 2, tostring(argcnt) }), level) + assert(util.callable(func), s("assertion.internal.badargtype", { 1, "error_matches", "function or callable object", type(func) }), level) + assert(pattern == nil or type(pattern) == "string", s("assertion.internal.badargtype", { 2, "error", "string", type(pattern) }), level) + + local failure_message + local init_arg_num = 3 + for i=3,argcnt,1 do + if arguments[i] and type(arguments[i]) ~= "boolean" and not tonumber(arguments[i]) then + if i == 3 then init_arg_num = init_arg_num + 1 end + failure_message = util.tremove(arguments, i) + break + end + end + local init = arguments[3] + local plain = arguments[4] + assert(init == nil or tonumber(init), s("assertion.internal.badargtype", { init_arg_num, "matches", "number", type(arguments[3]) }), level) + + local ok, err_actual = pcall(func) + if type(err_actual) == 'string' then + -- remove 'path/to/file:line: ' from string + err_actual = err_actual:gsub('^.-:%d+: ', '', 1) + end + retargs[1] = err_actual + arguments.nofmt = {} + arguments.n = 2 + arguments[1] = (ok and '(no error)' or err_actual) + arguments[2] = pattern + arguments.nofmt[1] = ok + arguments.nofmt[2] = false + set_failure_message(state, failure_message) + + if ok then return not ok, retargs end + if err_actual == nil and pattern == nil then + return true, {} + end + + -- err_actual must be (convertible to) a string + if util.hastostring(err_actual) or + type(err_actual) == "number" or + type(err_actual) == "boolean" then + err_actual = tostring(err_actual) + retargs[1] = err_actual + end + if type(err_actual) == 'string' then + local ok + local retargs_ok + if plain then + retargs_ok = { pattern } + ok = (err_actual:find(pattern, init, plain) ~= nil) + else + retargs_ok = { err_actual:match(pattern, init) } + ok = (retargs_ok[1] ~= nil) + end + if ok then retargs = retargs_ok end + return ok, retargs + end + + return false, retargs +end + +local function is_true(state, arguments, level) + util.tinsert(arguments, 2, true) + set_failure_message(state, arguments[3]) + return arguments[1] == arguments[2] +end + +local function is_false(state, arguments, level) + util.tinsert(arguments, 2, false) + set_failure_message(state, arguments[3]) + return arguments[1] == arguments[2] +end + +local function is_type(state, arguments, level, etype) + util.tinsert(arguments, 2, "type " .. etype) + arguments.nofmt = arguments.nofmt or {} + arguments.nofmt[2] = true + set_failure_message(state, arguments[3]) + return arguments.n > 1 and type(arguments[1]) == etype +end + +local function returned_arguments(state, arguments, level) + arguments[1] = tostring(arguments[1]) + arguments[2] = tostring(arguments.n - 1) + arguments.nofmt = arguments.nofmt or {} + arguments.nofmt[1] = true + arguments.nofmt[2] = true + if arguments.n < 2 then arguments.n = 2 end + return arguments[1] == arguments[2] +end + +local function set_message(state, arguments, level) + state.failure_message = arguments[1] +end + +local function is_boolean(state, arguments, level) return is_type(state, arguments, level, "boolean") end +local function is_number(state, arguments, level) return is_type(state, arguments, level, "number") end +local function is_string(state, arguments, level) return is_type(state, arguments, level, "string") end +local function is_table(state, arguments, level) return is_type(state, arguments, level, "table") end +local function is_nil(state, arguments, level) return is_type(state, arguments, level, "nil") end +local function is_userdata(state, arguments, level) return is_type(state, arguments, level, "userdata") end +local function is_function(state, arguments, level) return is_type(state, arguments, level, "function") end +local function is_thread(state, arguments, level) return is_type(state, arguments, level, "thread") end + +assert:register("modifier", "message", set_message) +assert:register("assertion", "true", is_true, "assertion.same.positive", "assertion.same.negative") +assert:register("assertion", "false", is_false, "assertion.same.positive", "assertion.same.negative") +assert:register("assertion", "boolean", is_boolean, "assertion.same.positive", "assertion.same.negative") +assert:register("assertion", "number", is_number, "assertion.same.positive", "assertion.same.negative") +assert:register("assertion", "string", is_string, "assertion.same.positive", "assertion.same.negative") +assert:register("assertion", "table", is_table, "assertion.same.positive", "assertion.same.negative") +assert:register("assertion", "nil", is_nil, "assertion.same.positive", "assertion.same.negative") +assert:register("assertion", "userdata", is_userdata, "assertion.same.positive", "assertion.same.negative") +assert:register("assertion", "function", is_function, "assertion.same.positive", "assertion.same.negative") +assert:register("assertion", "thread", is_thread, "assertion.same.positive", "assertion.same.negative") +assert:register("assertion", "returned_arguments", returned_arguments, "assertion.returned_arguments.positive", "assertion.returned_arguments.negative") + +assert:register("assertion", "same", same, "assertion.same.positive", "assertion.same.negative") +assert:register("assertion", "matches", matches, "assertion.matches.positive", "assertion.matches.negative") +assert:register("assertion", "match", matches, "assertion.matches.positive", "assertion.matches.negative") +assert:register("assertion", "near", near, "assertion.near.positive", "assertion.near.negative") +assert:register("assertion", "equals", equals, "assertion.equals.positive", "assertion.equals.negative") +assert:register("assertion", "equal", equals, "assertion.equals.positive", "assertion.equals.negative") +assert:register("assertion", "unique", unique, "assertion.unique.positive", "assertion.unique.negative") +assert:register("assertion", "error", has_error, "assertion.error.positive", "assertion.error.negative") +assert:register("assertion", "errors", has_error, "assertion.error.positive", "assertion.error.negative") +assert:register("assertion", "error_matches", error_matches, "assertion.error.positive", "assertion.error.negative") +assert:register("assertion", "error_match", error_matches, "assertion.error.positive", "assertion.error.negative") +assert:register("assertion", "matches_error", error_matches, "assertion.error.positive", "assertion.error.negative") +assert:register("assertion", "match_error", error_matches, "assertion.error.positive", "assertion.error.negative") +assert:register("assertion", "truthy", truthy, "assertion.truthy.positive", "assertion.truthy.negative") +assert:register("assertion", "falsy", falsy, "assertion.falsy.positive", "assertion.falsy.negative") diff --git a/test/integration/luassert/lua/luassert/compatibility.lua b/test/integration/luassert/lua/luassert/compatibility.lua new file mode 100644 index 0000000..88290ad --- /dev/null +++ b/test/integration/luassert/lua/luassert/compatibility.lua @@ -0,0 +1,9 @@ +-- no longer needed, only for backward compatibility +local unpack = require ("luassert.util").unpack + +return { + unpack = function(...) + print(debug.traceback("WARN: calling deprecated function 'luassert.compatibility.unpack' use 'luassert.util.unpack' instead")) + return unpack(...) + end +} diff --git a/test/integration/luassert/lua/luassert/formatters/binarystring.lua b/test/integration/luassert/lua/luassert/formatters/binarystring.lua new file mode 100644 index 0000000..02c05ea --- /dev/null +++ b/test/integration/luassert/lua/luassert/formatters/binarystring.lua @@ -0,0 +1,28 @@ +local format = function (str) + if type(str) ~= "string" then return nil end + local result = "Binary string length; " .. tostring(#str) .. " bytes\n" + local i = 1 + local hex = "" + local chr = "" + while i <= #str do + local byte = str:byte(i) + hex = string.format("%s%2x ", hex, byte) + if byte < 32 then byte = string.byte(".") end + chr = chr .. string.char(byte) + if math.floor(i/16) == i/16 or i == #str then + -- reached end of line + hex = hex .. string.rep(" ", 16 * 3 - #hex) + chr = chr .. string.rep(" ", 16 - #chr) + + result = result .. hex:sub(1, 8 * 3) .. " " .. hex:sub(8*3+1, -1) .. " " .. chr:sub(1,8) .. " " .. chr:sub(9,-1) .. "\n" + + hex = "" + chr = "" + end + i = i + 1 + end + return result +end + +return format + diff --git a/test/integration/luassert/lua/luassert/formatters/init.lua b/test/integration/luassert/lua/luassert/formatters/init.lua new file mode 100644 index 0000000..0ff67c9 --- /dev/null +++ b/test/integration/luassert/lua/luassert/formatters/init.lua @@ -0,0 +1,255 @@ +-- module will not return anything, only register formatters with the main assert engine +local assert = require('luassert.assert') +local match = require('luassert.match') +local util = require('luassert.util') + +local isatty, colors do + local ok, term = pcall(require, 'term') + isatty = io.type(io.stdout) == 'file' and ok and term.isatty(io.stdout) + if not isatty then + local isWindows = package.config:sub(1,1) == '\\' + if isWindows and os.getenv("ANSICON") then + isatty = true + end + end + + colors = setmetatable({ + none = function(c) return c end + },{ __index = function(self, key) + return function(c) + for token in key:gmatch("[^%.]+") do + c = term.colors[token](c) + end + return c + end + end + }) +end + +local function fmt_string(arg) + if type(arg) == "string" then + return string.format("(string) '%s'", arg) + end +end + +-- A version of tostring which formats numbers more precisely. +local function tostr(arg) + if type(arg) ~= "number" then + return tostring(arg) + end + + if arg ~= arg then + return "NaN" + elseif arg == 1/0 then + return "Inf" + elseif arg == -1/0 then + return "-Inf" + end + + local str = string.format("%.20g", arg) + + if math.type and math.type(arg) == "float" and not str:find("[%.,]") then + -- Number is a float but looks like an integer. + -- Insert ".0" after first run of digits. + str = str:gsub("%d+", "%0.0", 1) + end + + return str +end + +local function fmt_number(arg) + if type(arg) == "number" then + return string.format("(number) %s", tostr(arg)) + end +end + +local function fmt_boolean(arg) + if type(arg) == "boolean" then + return string.format("(boolean) %s", tostring(arg)) + end +end + +local function fmt_nil(arg) + if type(arg) == "nil" then + return "(nil)" + end +end + +local type_priorities = { + number = 1, + boolean = 2, + string = 3, + table = 4, + ["function"] = 5, + userdata = 6, + thread = 7 +} + +local function is_in_array_part(key, length) + return type(key) == "number" and 1 <= key and key <= length and math.floor(key) == key +end + +local function get_sorted_keys(t) + local keys = {} + local nkeys = 0 + + for key in pairs(t) do + nkeys = nkeys + 1 + keys[nkeys] = key + end + + local length = #t + + local function key_comparator(key1, key2) + local type1, type2 = type(key1), type(key2) + local priority1 = is_in_array_part(key1, length) and 0 or type_priorities[type1] or 8 + local priority2 = is_in_array_part(key2, length) and 0 or type_priorities[type2] or 8 + + if priority1 == priority2 then + if type1 == "string" or type1 == "number" then + return key1 < key2 + elseif type1 == "boolean" then + return key1 -- put true before false + end + else + return priority1 < priority2 + end + end + + table.sort(keys, key_comparator) + return keys, nkeys +end + +local function fmt_table(arg, fmtargs) + if type(arg) ~= "table" then + return + end + + local tmax = assert:get_parameter("TableFormatLevel") + local showrec = assert:get_parameter("TableFormatShowRecursion") + local errchar = assert:get_parameter("TableErrorHighlightCharacter") or "" + local errcolor = assert:get_parameter("TableErrorHighlightColor") + local crumbs = fmtargs and fmtargs.crumbs or {} + local cache = {} + local type_desc + + if getmetatable(arg) == nil then + type_desc = "(" .. tostring(arg) .. ") " + elseif not pcall(setmetatable, arg, getmetatable(arg)) then + -- cannot set same metatable, so it is protected, skip id + type_desc = "(table) " + else + -- unprotected metatable, temporary remove the mt + local mt = getmetatable(arg) + setmetatable(arg, nil) + type_desc = "(" .. tostring(arg) .. ") " + setmetatable(arg, mt) + end + + local function ft(t, l, with_crumbs) + if showrec and cache[t] and cache[t] > 0 then + return "{ ... recursive }" + end + + if next(t) == nil then + return "{ }" + end + + if l > tmax and tmax >= 0 then + return "{ ... more }" + end + + local result = "{" + local keys, nkeys = get_sorted_keys(t) + + cache[t] = (cache[t] or 0) + 1 + local crumb = crumbs[#crumbs - l + 1] + + for i = 1, nkeys do + local k = keys[i] + local v = t[k] + local use_crumbs = with_crumbs and k == crumb + + if type(v) == "table" then + v = ft(v, l + 1, use_crumbs) + elseif type(v) == "string" then + v = "'"..v.."'" + end + + local ch = use_crumbs and errchar or "" + local indent = string.rep(" ",l * 2 - ch:len()) + local mark = (ch:len() == 0 and "" or colors[errcolor](ch)) + result = result .. string.format("\n%s%s[%s] = %s", indent, mark, tostr(k), tostr(v)) + end + + cache[t] = cache[t] - 1 + + return result .. " }" + end + + return type_desc .. ft(arg, 1, true) +end + +local function fmt_function(arg) + if type(arg) == "function" then + local debug_info = debug.getinfo(arg) + return string.format("%s @ line %s in %s", tostring(arg), tostring(debug_info.linedefined), tostring(debug_info.source)) + end +end + +local function fmt_userdata(arg) + if type(arg) == "userdata" then + return string.format("(userdata) '%s'", tostring(arg)) + end +end + +local function fmt_thread(arg) + if type(arg) == "thread" then + return string.format("(thread) '%s'", tostring(arg)) + end +end + +local function fmt_matcher(arg) + if not match.is_matcher(arg) then + return + end + local not_inverted = { + [true] = "is.", + [false] = "no.", + } + local args = {} + for idx = 1, arg.arguments.n do + table.insert(args, assert:format({ arg.arguments[idx], n = 1, })[1]) + end + return string.format("(matcher) %s%s(%s)", + not_inverted[arg.mod], + tostring(arg.name), + table.concat(args, ", ")) +end + +local function fmt_arglist(arglist) + if not util.is_arglist(arglist) then + return + end + local formatted_vals = {} + for idx = 1, arglist.n do + table.insert(formatted_vals, assert:format({ arglist[idx], n = 1, })[1]) + end + return "(values list) (" .. table.concat(formatted_vals, ", ") .. ")" +end + +assert:add_formatter(fmt_string) +assert:add_formatter(fmt_number) +assert:add_formatter(fmt_boolean) +assert:add_formatter(fmt_nil) +assert:add_formatter(fmt_table) +assert:add_formatter(fmt_function) +assert:add_formatter(fmt_userdata) +assert:add_formatter(fmt_thread) +assert:add_formatter(fmt_matcher) +assert:add_formatter(fmt_arglist) +-- Set default table display depth for table formatter +assert:set_parameter("TableFormatLevel", 3) +assert:set_parameter("TableFormatShowRecursion", false) +assert:set_parameter("TableErrorHighlightCharacter", "*") +assert:set_parameter("TableErrorHighlightColor", isatty and "red" or "none") diff --git a/test/integration/luassert/lua/luassert/init.lua b/test/integration/luassert/lua/luassert/init.lua new file mode 100644 index 0000000..4ecf8b4 --- /dev/null +++ b/test/integration/luassert/lua/luassert/init.lua @@ -0,0 +1,17 @@ +local assert = require('luassert.assert') + +assert._COPYRIGHT = "Copyright (c) 2018 Olivine Labs, LLC." +assert._DESCRIPTION = "Extends Lua's built-in assertions to provide additional tests and the ability to create your own." +assert._VERSION = "Luassert 1.8.0" + +-- load basic asserts +require('luassert.assertions') +require('luassert.modifiers') +require('luassert.array') +require('luassert.matchers') +require('luassert.formatters') + +-- load default language +require('luassert.languages.en') + +return assert diff --git a/test/integration/luassert/lua/luassert/languages/ar.lua b/test/integration/luassert/lua/luassert/languages/ar.lua new file mode 100644 index 0000000..fb4d86c --- /dev/null +++ b/test/integration/luassert/lua/luassert/languages/ar.lua @@ -0,0 +1,21 @@ +local s = require('say') + +s:set_namespace("ar") + +s:set("assertion.same.positive", "تُوُقِّعَ تَماثُلُ الكائِنات.\nتَمَّ إدخال:\n %s.\nبَينَما كانَ مِن المُتَوقَّع:\n %s.") +s:set("assertion.same.negative", "تُوُقِّعَ إختِلافُ الكائِنات.\nتَمَّ إدخال:\n %s.\nبَينَما كانَ مِن غَيرِ المُتَوقَّع:\n %s.") + +s:set("assertion.equals.positive", "تُوُقِّعَ أن تَتَساوىْ الكائِنات.\nتمَّ إِدخال:\n %s.\nبَينَما كانَ من المُتَوقَّع:\n %s.") +s:set("assertion.equals.negative", "تُوُقِّعَ ألّا تَتَساوىْ الكائِنات.\nتمَّ إِدخال:\n %s.\nبَينَما كانَ مِن غير المُتًوقَّع:\n %s.") + +s:set("assertion.unique.positive", "تُوُقِّعَ أَنْ يَكونَ الكائِنٌ فَريد: \n%s") +s:set("assertion.unique.negative", "تُوُقِّعَ أنْ يَكونَ الكائِنٌ غَيرَ فَريد: \n%s") + +s:set("assertion.error.positive", "تُوُقِّعَ إصدارُ خطأْ.") +s:set("assertion.error.negative", "تُوُقِّعَ عدم إصدارِ خطأ.") + +s:set("assertion.truthy.positive", "تُوُقِّعَت قيمةٌ صَحيحة، بينما كانت: \n%s") +s:set("assertion.truthy.negative", "تُوُقِّعَت قيمةٌ غيرُ صَحيحة، بينما كانت: \n%s") + +s:set("assertion.falsy.positive", "تُوُقِّعَت قيمةٌ خاطِئة، بَينَما كانت: \n%s") +s:set("assertion.falsy.negative", "تُوُقِّعَت قيمةٌ غيرُ خاطِئة، بَينَما كانت: \n%s") diff --git a/test/integration/luassert/lua/luassert/languages/de.lua b/test/integration/luassert/lua/luassert/languages/de.lua new file mode 100644 index 0000000..7f0cf56 --- /dev/null +++ b/test/integration/luassert/lua/luassert/languages/de.lua @@ -0,0 +1,48 @@ +local s = require('say') + +s:set_namespace('de') + +s:set("assertion.same.positive", "Erwarte gleiche Objekte.\nGegeben:\n%s\nErwartet:\n%s") +s:set("assertion.same.negative", "Erwarte ungleiche Objekte.\nGegeben:\n%s\nNicht erwartet:\n%s") + +s:set("assertion.equals.positive", "Erwarte dieselben Objekte.\nGegeben:\n%s\nErwartet:\n%s") +s:set("assertion.equals.negative", "Erwarte nicht dieselben Objekte.\nGegeben:\n%s\nNicht erwartet:\n%s") + +s:set("assertion.near.positive", "Erwarte annähernd gleiche Werte.\nGegeben:\n%s\nErwartet:\n%s +/- %s") +s:set("assertion.near.negative", "Erwarte keine annähernd gleichen Werte.\nGegeben:\n%s\nNicht erwartet:\n%s +/- %s") + +s:set("assertion.matches.positive", "Erwarte identische Zeichenketten.\nGegeben:\n%s\nErwartet:\n%s") +s:set("assertion.matches.negative", "Erwarte unterschiedliche Zeichenketten.\nGegeben:\n%s\nNicht erwartet:\n%s") + +s:set("assertion.unique.positive", "Erwarte einzigartiges Objekt:\n%s") +s:set("assertion.unique.negative", "Erwarte nicht einzigartiges Objekt:\n%s") + +s:set("assertion.error.positive", "Es wird ein Fehler erwartet.") +s:set("assertion.error.negative", "Es wird kein Fehler erwartet, aber folgender Fehler trat auf:\n%s") + +s:set("assertion.truthy.positive", "Erwarte, dass der Wert 'wahr' (truthy) ist.\nGegeben:\n%s") +s:set("assertion.truthy.negative", "Erwarte, dass der Wert 'unwahr' ist (falsy).\nGegeben:\n%s") + +s:set("assertion.falsy.positive", "Erwarte, dass der Wert 'unwahr' ist (falsy).\nGegeben:\n%s") +s:set("assertion.falsy.negative", "Erwarte, dass der Wert 'wahr' (truthy) ist.\nGegeben:\n%s") + +s:set("assertion.called.positive", "Erwarte, dass die Funktion %s-mal aufgerufen wird, anstatt %s mal.") +s:set("assertion.called.negative", "Erwarte, dass die Funktion nicht genau %s-mal aufgerufen wird.") + +s:set("assertion.called_at_least.positive", "Erwarte, dass die Funktion mindestens %s-mal aufgerufen wird, anstatt %s-mal") +s:set("assertion.called_at_most.positive", "Erwarte, dass die Funktion höchstens %s-mal aufgerufen wird, anstatt %s-mal") +s:set("assertion.called_more_than.positive", "Erwarte, dass die Funktion mehr als %s-mal aufgerufen wird, anstatt %s-mal") +s:set("assertion.called_less_than.positive", "Erwarte, dass die Funktion weniger als %s-mal aufgerufen wird, anstatt %s-mal") + +s:set("assertion.called_with.positive", "Erwarte, dass die Funktion mit den gegebenen Parametern aufgerufen wird.") +s:set("assertion.called_with.negative", "Erwarte, dass die Funktion nicht mit den gegebenen Parametern aufgerufen wird.") + +s:set("assertion.returned_with.positive", "Die Funktion wurde nicht mit den Argumenten zurückgegeben.") +s:set("assertion.returned_with.negative", "Die Funktion wurde mit den Argumenten zurückgegeben.") + +s:set("assertion.returned_arguments.positive", "Erwarte den Aufruf mit %s Argument(en), aber der Aufruf erfolgte mit %s") +s:set("assertion.returned_arguments.negative", "Erwarte nicht den Aufruf mit %s Argument(en), der Aufruf erfolgte dennoch mit %s") + +-- errors +s:set("assertion.internal.argtolittle", "Die Funktion '%s' erwartet mindestens %s Parameter, gegeben: %s") +s:set("assertion.internal.badargtype", "bad argument #%s: Die Funktion '%s' erwartet einen Parameter vom Typ %s, gegeben: %s") diff --git a/test/integration/luassert/lua/luassert/languages/en.lua b/test/integration/luassert/lua/luassert/languages/en.lua new file mode 100644 index 0000000..0d84b6d --- /dev/null +++ b/test/integration/luassert/lua/luassert/languages/en.lua @@ -0,0 +1,48 @@ +local s = require('say') + +s:set_namespace('en') + +s:set("assertion.same.positive", "Expected objects to be the same.\nPassed in:\n%s\nExpected:\n%s") +s:set("assertion.same.negative", "Expected objects to not be the same.\nPassed in:\n%s\nDid not expect:\n%s") + +s:set("assertion.equals.positive", "Expected objects to be equal.\nPassed in:\n%s\nExpected:\n%s") +s:set("assertion.equals.negative", "Expected objects to not be equal.\nPassed in:\n%s\nDid not expect:\n%s") + +s:set("assertion.near.positive", "Expected values to be near.\nPassed in:\n%s\nExpected:\n%s +/- %s") +s:set("assertion.near.negative", "Expected values to not be near.\nPassed in:\n%s\nDid not expect:\n%s +/- %s") + +s:set("assertion.matches.positive", "Expected strings to match.\nPassed in:\n%s\nExpected:\n%s") +s:set("assertion.matches.negative", "Expected strings not to match.\nPassed in:\n%s\nDid not expect:\n%s") + +s:set("assertion.unique.positive", "Expected object to be unique:\n%s") +s:set("assertion.unique.negative", "Expected object to not be unique:\n%s") + +s:set("assertion.error.positive", "Expected a different error.\nCaught:\n%s\nExpected:\n%s") +s:set("assertion.error.negative", "Expected no error, but caught:\n%s") + +s:set("assertion.truthy.positive", "Expected to be truthy, but value was:\n%s") +s:set("assertion.truthy.negative", "Expected to not be truthy, but value was:\n%s") + +s:set("assertion.falsy.positive", "Expected to be falsy, but value was:\n%s") +s:set("assertion.falsy.negative", "Expected to not be falsy, but value was:\n%s") + +s:set("assertion.called.positive", "Expected to be called %s time(s), but was called %s time(s)") +s:set("assertion.called.negative", "Expected not to be called exactly %s time(s), but it was.") + +s:set("assertion.called_at_least.positive", "Expected to be called at least %s time(s), but was called %s time(s)") +s:set("assertion.called_at_most.positive", "Expected to be called at most %s time(s), but was called %s time(s)") +s:set("assertion.called_more_than.positive", "Expected to be called more than %s time(s), but was called %s time(s)") +s:set("assertion.called_less_than.positive", "Expected to be called less than %s time(s), but was called %s time(s)") + +s:set("assertion.called_with.positive", "Function was never called with matching arguments.\nCalled with (last call if any):\n%s\nExpected:\n%s") +s:set("assertion.called_with.negative", "Function was called with matching arguments at least once.\nCalled with (last matching call):\n%s\nDid not expect:\n%s") + +s:set("assertion.returned_with.positive", "Function never returned matching arguments.\nReturned (last call if any):\n%s\nExpected:\n%s") +s:set("assertion.returned_with.negative", "Function returned matching arguments at least once.\nReturned (last matching call):\n%s\nDid not expect:\n%s") + +s:set("assertion.returned_arguments.positive", "Expected to be called with %s argument(s), but was called with %s") +s:set("assertion.returned_arguments.negative", "Expected not to be called with %s argument(s), but was called with %s") + +-- errors +s:set("assertion.internal.argtolittle", "the '%s' function requires a minimum of %s arguments, got: %s") +s:set("assertion.internal.badargtype", "bad argument #%s to '%s' (%s expected, got %s)") diff --git a/test/integration/luassert/lua/luassert/languages/fr.lua b/test/integration/luassert/lua/luassert/languages/fr.lua new file mode 100644 index 0000000..360e31f --- /dev/null +++ b/test/integration/luassert/lua/luassert/languages/fr.lua @@ -0,0 +1,50 @@ +local s = require('say') + +s:set_namespace('fr') + +s:set("assertion.called.positive", "Prévu pour être appelé %s fois(s), mais a été appelé %s fois(s).") +s:set("assertion.called.negative", "Prévu de ne pas être appelé exactement %s fois(s), mais ceci a été le cas.") + +s:set("assertion.called_at_least.positive", "Prévu pour être appelé au moins %s fois(s), mais a été appelé %s fois(s).") +s:set("assertion.called_at_most.positive", "Prévu pour être appelé au plus %s fois(s), mais a été appelé %s fois(s).") + +s:set("assertion.called_more_than.positive", "Devrait être appelé plus de %s fois(s), mais a été appelé %s fois(s).") +s:set("assertion.called_less_than.positive", "Devrait être appelé moins de %s fois(s), mais a été appelé %s fois(s).") + +s:set("assertion.called_with.positive", "La fonction n'a pas été appelée avec les arguments.") +s:set("assertion.called_with.negative", "La fonction a été appelée avec les arguments.") + +s:set("assertion.equals.positive", "Les objets attendus doivent être égaux. \n Argument passé en: \n %s \n Attendu: \n %s.") +s:set("assertion.equals.negative", "Les objets attendus ne doivent pas être égaux. \n Argument passé en: \n %s \n Non attendu: \n %s.") + +s:set("assertion.error.positive", "Une erreur différente est attendue. \n Prise: \n %s \n Attendue: \n %s.") +s:set("assertion.error.negative", "Aucune erreur attendue, mais prise: \n %s.") + +s:set("assertion.falsy.positive", "Assertion supposée etre fausse mais de valeur: \n %s") +s:set("assertion.falsy.negative", "Assertion supposée etre vraie mais de valeur: \n %s") + +-- errors +s:set("assertion.internal.argtolittle", "La fonction '%s' requiert un minimum de %s arguments, obtenu: %s.") +s:set("assertion.internal.badargtype", "Mauvais argument #%s pour '%s' (%s attendu, obtenu %s).") +-- errors + +s:set("assertion.matches.positive", "Chaînes attendues pour correspondre. \n Argument passé en: \n %s \n Attendu: \n %s.") +s:set("assertion.matches.negative", "Les chaînes attendues ne doivent pas correspondre. \n Argument passé en: \n %s \n Non attendu: \n %s.") + +s:set("assertion.near.positive", "Les valeurs attendues sont proches. \n Argument passé en: \n %s \n Attendu: \n %s +/- %s.") +s:set("assertion.near.negative", "Les valeurs attendues ne doivent pas être proches. \n Argument passé en: \n %s \n Non attendu: \n %s +/- %s.") + +s:set("assertion.returned_arguments.positive", "Attendu pour être appelé avec le(s) argument(s) %s, mais a été appelé avec %s.") +s:set("assertion.returned_arguments.negative", "Attendu pour ne pas être appelé avec le(s) argument(s) %s, mais a été appelé avec %s.") + +s:set("assertion.returned_with.positive", "La fonction n'a pas été retournée avec les arguments.") +s:set("assertion.returned_with.negative", "La fonction a été retournée avec les arguments.") + +s:set("assertion.same.positive", "Les objets attendus sont les mêmes. \n Argument passé en: \n %s \n Attendu: \n %s.") +s:set("assertion.same.negative", "Les objets attendus ne doivent pas être les mêmes. \n Argument passé en: \n %s \n Non attendu: \n %s.") + +s:set("assertion.truthy.positive", "Assertion supposee etre vraie mais de valeur: \n %s") +s:set("assertion.truthy.negative", "Assertion supposee etre fausse mais de valeur: \n %s") + +s:set("assertion.unique.positive", "Objet attendu pour être unique: \n %s.") +s:set("assertion.unique.negative", "Objet attendu pour ne pas être unique: \n %s.") diff --git a/test/integration/luassert/lua/luassert/languages/is.lua b/test/integration/luassert/lua/luassert/languages/is.lua new file mode 100644 index 0000000..b776f6a --- /dev/null +++ b/test/integration/luassert/lua/luassert/languages/is.lua @@ -0,0 +1,48 @@ +local s = require('say') + +s:set_namespace('is') + +s:set("assertion.same.positive", "Átti von á að vera eins.\nSett inn:\n%s\nBjóst við:\n%s") +s:set("assertion.same.negative", "Átti von á að ekki vera eins.\nSett inn:\n%s\nBjóst ekki við:\n%s") + +s:set("assertion.equals.positive", "Átti von á að vera jöfn.\nSett inn:\n%s\nBjóst við:\n%s") +s:set("assertion.equals.negative", "Átti von á að ekki vera jöfn.\nSett inn:\n%s\nBjóst ekki við:\n%s") + +s:set("assertion.near.positive", "Átti von á að vera svipuð.\nSett inn:\n%s\nBjóst við:\n%s +/- %s") +s:set("assertion.near.negative", "Átti von á að ekki vera svipuð.\nSett inn:\n%s\nBjóst ekki við:\n%s +/- %s") + +s:set("assertion.matches.positive", "Átti von á að strengir væru eins.\nSett inn:\n%s\nBjóst við:\n%s") +s:set("assertion.matches.negative", "Átti von á að strengir væru ekki eins.\nSett inn:\n%s\nBjóst ekki við:\n%s") + +s:set("assertion.unique.positive", "Átti von á að vera einstök:\n%s") +s:set("assertion.unique.negative", "Átti von á að ekki vera einstök:\n%s") + +s:set("assertion.error.positive", "Átti von á annarri villu.\nFékk:\n%s\nBjóst við:\n%s") +s:set("assertion.error.negative", "Átti ekki von á neinni villu en fékk:\n%s") + +s:set("assertion.truthy.positive", "Átti von á sönnu gildi en gildi var:\n%s") +s:set("assertion.truthy.negative", "Átti ekki von á sönnu gildi en gildi var:\n%s") + +s:set("assertion.falsy.positive", "Átti von á ósönnu gildi en gildi var:\n%s") +s:set("assertion.falsy.negative", "Átti ekki von á ósönnu gildi en gildi var:\n%s") + +s:set("assertion.called.positive", "Átti von á að vera kallað %s sinnum en var kallað %s sinnum") +s:set("assertion.called.negative", "Átti von á að ekki vera kallað %s sinnum en var") + +s:set("assertion.called_at_least.positive", "Átti von á að vera kallað að minnsta kosti %s sinnum an var kallað %s sinnum") +s:set("assertion.called_at_most.positive", "Átti von á að vera kallað að mesta lagi %s sinnum an var kallað %s sinnum") +s:set("assertion.called_more_than.positive", "Átti von á að vera kallað oftar en %s sinnum an var kallað %s sinnum") +s:set("assertion.called_less_than.positive", "Átti von á að vera kallað færra en %s sinnum an var kallað %s sinnum") + +s:set("assertion.called_with.positive", "Undirforrit var aldrei kallað með passandi færibreytum.\nKallað með (síðasta):\n%s\nBjóst við:\n%s") +s:set("assertion.called_with.negative", "Undirforrit var kallað með passandi færibreytum.\nKallað með (síðasta):\n%s\nBjóst ekki við:\n%s") + +s:set("assertion.returned_with.positive", "Undirforrit skilaði aldrei samsvarandi breytu.\nSkilað (síðasta):\n%s\nBjóst við:\n%s") +s:set("assertion.returned_with.negative", "Undirforrit skilaði samsvarandi breytu.\nSkilað (síðasta):\n%s\nBjóst ekki við:\n%s") + +s:set("assertion.returned_arguments.positive", "Átti von á að vera kallað með færibreytum %s en var kallað með %s") +s:set("assertion.returned_arguments.negative", "Átti von á að ekki vera kallað með færibreytum %s en var kallað með %s") + +-- errors +s:set("assertion.internal.argtolittle", "undirforritið „%s“ krefst lágmarks %s færibreyti en fékk: %s") +s:set("assertion.internal.badargtype", "slæmt færibreyti #%s til „%s“ (átti von á %s en fékk %s)") diff --git a/test/integration/luassert/lua/luassert/languages/ja.lua b/test/integration/luassert/lua/luassert/languages/ja.lua new file mode 100644 index 0000000..2ad54c3 --- /dev/null +++ b/test/integration/luassert/lua/luassert/languages/ja.lua @@ -0,0 +1,34 @@ +local s = require('say') + +s:set_namespace('ja') + +s:set("assertion.same.positive", "オブジェクトの内容が同一であることが期待されています。\n実際の値:\n%s\n期待されている値:\n%s") +s:set("assertion.same.negative", "オブジェクトの内容が同一でないことが期待されています。\n実際の値:\n%s\n期待されていない値:\n%s") + +s:set("assertion.equals.positive", "オブジェクトが同一であることが期待されています。\n実際の値:\n%s\n期待されている値:\n%s") +s:set("assertion.equals.negative", "オブジェクトが同一でないことが期待されています。\n実際の値:\n%s\n期待されていない値:\n%s") + +s:set("assertion.unique.positive", "オブジェクトがユニークであることが期待されています。:\n%s") +s:set("assertion.unique.negative", "オブジェクトがユニークでないことが期待されています。:\n%s") + +s:set("assertion.error.positive", "エラーが発生することが期待されています。") +s:set("assertion.error.negative", "エラーが発生しないことが期待されています。") + +s:set("assertion.truthy.positive", "真であることが期待されていますが、値は:\n%s") +s:set("assertion.truthy.negative", "真でないことが期待されていますが、値は:\n%s") + +s:set("assertion.falsy.positive", "偽であることが期待されていますが、値は:\n%s") +s:set("assertion.falsy.negative", "偽でないことが期待されていますが、値は:\n%s") + +s:set("assertion.called.positive", "回呼ばれることを期待されていますが、実際には%s回呼ばれています。") +s:set("assertion.called.negative", "回呼ばれることを期待されていますが、実際には%s回呼ばれています。") + +s:set("assertion.called_with.positive", "関数が期待されている引数で呼ばれていません") +s:set("assertion.called_with.negative", "関数が期待されている引数で呼ばれています") + +s:set("assertion.returned_arguments.positive", "期待されている返り値の数は%sですが、実際の返り値の数は%sです。") +s:set("assertion.returned_arguments.negative", "期待されていない返り値の数は%sですが、実際の返り値の数は%sです。") + +-- errors +s:set("assertion.internal.argtolittle", "関数には最低%s個の引数が必要ですが、実際の引数の数は: %s") +s:set("assertion.internal.badargtype", "bad argument #%s: 関数には%s個の引数が必要ですが、実際に引数の数は: %s") diff --git a/test/integration/luassert/lua/luassert/languages/nl.lua b/test/integration/luassert/lua/luassert/languages/nl.lua new file mode 100644 index 0000000..a3550d3 --- /dev/null +++ b/test/integration/luassert/lua/luassert/languages/nl.lua @@ -0,0 +1,25 @@ +local s = require('say') + +s:set_namespace('nl') + +s:set("assertion.same.positive", "Verwachtte objecten die vergelijkbaar zijn.\nAangeboden:\n%s\nVerwachtte:\n%s") +s:set("assertion.same.negative", "Verwachtte objecten die niet vergelijkbaar zijn.\nAangeboden:\n%s\nVerwachtte niet:\n%s") + +s:set("assertion.equals.positive", "Verwachtte objecten die hetzelfde zijn.\nAangeboden:\n%s\nVerwachtte:\n%s") +s:set("assertion.equals.negative", "Verwachtte objecten die niet hetzelfde zijn.\nAangeboden:\n%s\nVerwachtte niet:\n%s") + +s:set("assertion.unique.positive", "Verwachtte objecten die uniek zijn:\n%s") +s:set("assertion.unique.negative", "Verwachtte objecten die niet uniek zijn:\n%s") + +s:set("assertion.error.positive", "Verwachtte een foutmelding.") +s:set("assertion.error.negative", "Verwachtte geen foutmelding.\n%s") + +s:set("assertion.truthy.positive", "Verwachtte een 'warige' (thruthy) waarde, maar was:\n%s") +s:set("assertion.truthy.negative", "Verwachtte een niet 'warige' (thruthy) waarde, maar was:\n%s") + +s:set("assertion.falsy.positive", "Verwachtte een 'onwarige' (falsy) waarde, maar was:\n%s") +s:set("assertion.falsy.negative", "Verwachtte een niet 'onwarige' (falsy) waarde, maar was:\n%s") + +-- errors +s:set("assertion.internal.argtolittle", "de '%s' functie verwacht minimaal %s parameters, maar kreeg er: %s") +s:set("assertion.internal.badargtype", "bad argument #%s: de '%s' functie verwacht een %s als parameter, maar kreeg een: %s") diff --git a/test/integration/luassert/lua/luassert/languages/ru.lua b/test/integration/luassert/lua/luassert/languages/ru.lua new file mode 100644 index 0000000..5e346a7 --- /dev/null +++ b/test/integration/luassert/lua/luassert/languages/ru.lua @@ -0,0 +1,21 @@ +local s = require('say') + +s:set_namespace("ru") + +s:set("assertion.same.positive", "Ожидали одинаковые объекты.\nПередали:\n%s\nОжидали:\n%s") +s:set("assertion.same.negative", "Ожидали разные объекты.\nПередали:\n%s\nНе ожидали:\n%s") + +s:set("assertion.equals.positive", "Ожидали эквивалентные объекты.\nПередали:\n%s\nОжидали:\n%s") +s:set("assertion.equals.negative", "Ожидали не эквивалентные объекты.\nПередали:\n%s\nНе ожидали:\n%s") + +s:set("assertion.unique.positive", "Ожидали, что объект будет уникальным:\n%s") +s:set("assertion.unique.negative", "Ожидали, что объект не будет уникальным:\n%s") + +s:set("assertion.error.positive", "Ожидали ошибку.") +s:set("assertion.error.negative", "Не ожидали ошибку.\n%s") + +s:set("assertion.truthy.positive", "Ожидали true, но значние оказалось:\n%s") +s:set("assertion.truthy.negative", "Ожидали не true, но значние оказалось:\n%s") + +s:set("assertion.falsy.positive", "Ожидали false, но значние оказалось:\n%s") +s:set("assertion.falsy.negative", "Ожидали не false, но значние оказалось:\n%s") diff --git a/test/integration/luassert/lua/luassert/languages/ua.lua b/test/integration/luassert/lua/luassert/languages/ua.lua new file mode 100644 index 0000000..3fd382a --- /dev/null +++ b/test/integration/luassert/lua/luassert/languages/ua.lua @@ -0,0 +1,21 @@ +local s = require('say') + +s:set_namespace("ua") + +s:set("assertion.same.positive", "Очікували однакові обєкти.\nПередали:\n%s\nОчікували:\n%s") +s:set("assertion.same.negative", "Очікували різні обєкти.\nПередали:\n%s\nНе очікували:\n%s") + +s:set("assertion.equals.positive", "Очікували еквівалентні обєкти.\nПередали:\n%s\nОчікували:\n%s") +s:set("assertion.equals.negative", "Очікували не еквівалентні обєкти.\nПередали:\n%s\nНе очікували:\n%s") + +s:set("assertion.unique.positive", "Очікували, що обєкт буде унікальним:\n%s") +s:set("assertion.unique.negative", "Очікували, що обєкт не буде унікальним:\n%s") + +s:set("assertion.error.positive", "Очікували помилку.") +s:set("assertion.error.negative", "Не очікували помилку.\n%s") + +s:set("assertion.truthy.positive", "Очікували true, проте значння виявилось:\n%s") +s:set("assertion.truthy.negative", "Очікували не true, проте значння виявилось:\n%s") + +s:set("assertion.falsy.positive", "Очікували false, проте значння виявилось:\n%s") +s:set("assertion.falsy.negative", "Очікували не false, проте значння виявилось:\n%s") diff --git a/test/integration/luassert/lua/luassert/languages/zh.lua b/test/integration/luassert/lua/luassert/languages/zh.lua new file mode 100644 index 0000000..054cc69 --- /dev/null +++ b/test/integration/luassert/lua/luassert/languages/zh.lua @@ -0,0 +1,31 @@ +local s = require('say') + +s:set_namespace('zh') + +s:set("assertion.same.positive", "希望对象应该相同.\n实际值:\n%s\n希望值:\n%s") +s:set("assertion.same.negative", "希望对象应该不相同.\n实际值:\n%s\n不希望与:\n%s\n相同") + +s:set("assertion.equals.positive", "希望对象应该相等.\n实际值:\n%s\n希望值:\n%s") +s:set("assertion.equals.negative", "希望对象应该不相等.\n实际值:\n%s\n不希望等于:\n%s") + +s:set("assertion.unique.positive", "希望对象是唯一的:\n%s") +s:set("assertion.unique.negative", "希望对象不是唯一的:\n%s") + +s:set("assertion.error.positive", "希望有错误被抛出.") +s:set("assertion.error.negative", "希望没有错误被抛出.\n%s") + +s:set("assertion.truthy.positive", "希望结果为真,但是实际为:\n%s") +s:set("assertion.truthy.negative", "希望结果不为真,但是实际为:\n%s") + +s:set("assertion.falsy.positive", "希望结果为假,但是实际为:\n%s") +s:set("assertion.falsy.negative", "希望结果不为假,但是实际为:\n%s") + +s:set("assertion.called.positive", "希望被调用%s次, 但实际被调用了%s次") +s:set("assertion.called.negative", "不希望正好被调用%s次, 但是正好被调用了那么多次.") + +s:set("assertion.called_with.positive", "希望没有参数的调用函数") +s:set("assertion.called_with.negative", "希望有参数的调用函数") + +-- errors +s:set("assertion.internal.argtolittle", "函数'%s'需要最少%s个参数, 实际有%s个参数\n") +s:set("assertion.internal.badargtype", "bad argument #%s: 函数'%s'需要一个%s作为参数, 实际为: %s\n") diff --git a/test/integration/luassert/lua/luassert/match.lua b/test/integration/luassert/lua/luassert/match.lua new file mode 100644 index 0000000..671c82f --- /dev/null +++ b/test/integration/luassert/lua/luassert/match.lua @@ -0,0 +1,79 @@ +local namespace = require 'luassert.namespaces' +local util = require 'luassert.util' + +local matcher_mt = { + __call = function(self, value) + return self.callback(value) == self.mod + end, +} + +local state_mt = { + __call = function(self, ...) + local keys = util.extract_keys("matcher", self.tokens) + self.tokens = {} + + local matcher + + for _, key in ipairs(keys) do + matcher = namespace.matcher[key] or matcher + end + + if matcher then + for _, key in ipairs(keys) do + if namespace.modifier[key] then + namespace.modifier[key].callback(self) + end + end + + local arguments = util.make_arglist(...) + local matches = matcher.callback(self, arguments, util.errorlevel()) + return setmetatable({ + name = matcher.name, + mod = self.mod, + callback = matches, + arguments = arguments, + }, matcher_mt) + else + local arguments = util.make_arglist(...) + + for _, key in ipairs(keys) do + if namespace.modifier[key] then + namespace.modifier[key].callback(self, arguments, util.errorlevel()) + end + end + end + + return self + end, + + __index = function(self, key) + for token in key:lower():gmatch('[^_]+') do + table.insert(self.tokens, token) + end + + return self + end +} + +local match = { + _ = setmetatable({mod=true, callback=function() return true end}, matcher_mt), + + state = function() return setmetatable({mod=true, tokens={}}, state_mt) end, + + is_matcher = function(object) + return type(object) == "table" and getmetatable(object) == matcher_mt + end, + + is_ref_matcher = function(object) + local ismatcher = (type(object) == "table" and getmetatable(object) == matcher_mt) + return ismatcher and object.name == "ref" + end, +} + +local mt = { + __index = function(self, key) + return rawget(self, key) or self.state()[key] + end, +} + +return setmetatable(match, mt) diff --git a/test/integration/luassert/lua/luassert/matchers/composite.lua b/test/integration/luassert/lua/luassert/matchers/composite.lua new file mode 100644 index 0000000..e4775e0 --- /dev/null +++ b/test/integration/luassert/lua/luassert/matchers/composite.lua @@ -0,0 +1,61 @@ +local assert = require('luassert.assert') +local match = require ('luassert.match') +local s = require('say') + +local function none(state, arguments, level) + local level = (level or 1) + 1 + local argcnt = arguments.n + assert(argcnt > 0, s("assertion.internal.argtolittle", { "none", 1, tostring(argcnt) }), level) + for i = 1, argcnt do + assert(match.is_matcher(arguments[i]), s("assertion.internal.badargtype", { 1, "none", "matcher", type(arguments[i]) }), level) + end + + return function(value) + for _, matcher in ipairs(arguments) do + if matcher(value) then + return false + end + end + return true + end +end + +local function any(state, arguments, level) + local level = (level or 1) + 1 + local argcnt = arguments.n + assert(argcnt > 0, s("assertion.internal.argtolittle", { "any", 1, tostring(argcnt) }), level) + for i = 1, argcnt do + assert(match.is_matcher(arguments[i]), s("assertion.internal.badargtype", { 1, "any", "matcher", type(arguments[i]) }), level) + end + + return function(value) + for _, matcher in ipairs(arguments) do + if matcher(value) then + return true + end + end + return false + end +end + +local function all(state, arguments, level) + local level = (level or 1) + 1 + local argcnt = arguments.n + assert(argcnt > 0, s("assertion.internal.argtolittle", { "all", 1, tostring(argcnt) }), level) + for i = 1, argcnt do + assert(match.is_matcher(arguments[i]), s("assertion.internal.badargtype", { 1, "all", "matcher", type(arguments[i]) }), level) + end + + return function(value) + for _, matcher in ipairs(arguments) do + if not matcher(value) then + return false + end + end + return true + end +end + +assert:register("matcher", "none_of", none) +assert:register("matcher", "any_of", any) +assert:register("matcher", "all_of", all) diff --git a/test/integration/luassert/lua/luassert/matchers/core.lua b/test/integration/luassert/lua/luassert/matchers/core.lua new file mode 100644 index 0000000..4335baf --- /dev/null +++ b/test/integration/luassert/lua/luassert/matchers/core.lua @@ -0,0 +1,173 @@ +-- module will return the list of matchers, and registers matchers with the main assert engine + +-- matchers take 1 parameters; +-- 1) state +-- 2) arguments list. The list has a member 'n' with the argument count to check for trailing nils +-- 3) level The level of the error position relative to the called function +-- returns; function (or callable object); a function that, given an argument, returns a boolean + +local assert = require('luassert.assert') +local astate = require('luassert.state') +local util = require('luassert.util') +local s = require('say') + +local function format(val) + return astate.format_argument(val) or tostring(val) +end + +local function unique(state, arguments, level) + local deep = arguments[1] + return function(value) + local list = value + for k,v in pairs(list) do + for k2, v2 in pairs(list) do + if k ~= k2 then + if deep and util.deepcompare(v, v2, true) then + return false + else + if v == v2 then + return false + end + end + end + end + end + return true + end +end + +local function near(state, arguments, level) + local level = (level or 1) + 1 + local argcnt = arguments.n + assert(argcnt > 1, s("assertion.internal.argtolittle", { "near", 2, tostring(argcnt) }), level) + local expected = tonumber(arguments[1]) + local tolerance = tonumber(arguments[2]) + local numbertype = "number or object convertible to a number" + assert(expected, s("assertion.internal.badargtype", { 1, "near", numbertype, format(arguments[1]) }), level) + assert(tolerance, s("assertion.internal.badargtype", { 2, "near", numbertype, format(arguments[2]) }), level) + + return function(value) + local actual = tonumber(value) + if not actual then return false end + return (actual >= expected - tolerance and actual <= expected + tolerance) + end +end + +local function matches(state, arguments, level) + local level = (level or 1) + 1 + local argcnt = arguments.n + assert(argcnt > 0, s("assertion.internal.argtolittle", { "matches", 1, tostring(argcnt) }), level) + local pattern = arguments[1] + local init = arguments[2] + local plain = arguments[3] + assert(type(pattern) == "string", s("assertion.internal.badargtype", { 1, "matches", "string", type(arguments[1]) }), level) + assert(init == nil or tonumber(init), s("assertion.internal.badargtype", { 2, "matches", "number", type(arguments[2]) }), level) + + return function(value) + local actualtype = type(value) + local actual = nil + if actualtype == "string" or actualtype == "number" or + actualtype == "table" and (getmetatable(value) or {}).__tostring then + actual = tostring(value) + end + if not actual then return false end + return (actual:find(pattern, init, plain) ~= nil) + end +end + +local function equals(state, arguments, level) + local level = (level or 1) + 1 + local argcnt = arguments.n + assert(argcnt > 0, s("assertion.internal.argtolittle", { "equals", 1, tostring(argcnt) }), level) + return function(value) + return value == arguments[1] + end +end + +local function same(state, arguments, level) + local level = (level or 1) + 1 + local argcnt = arguments.n + assert(argcnt > 0, s("assertion.internal.argtolittle", { "same", 1, tostring(argcnt) }), level) + return function(value) + if type(value) == 'table' and type(arguments[1]) == 'table' then + local result = util.deepcompare(value, arguments[1], true) + return result + end + return value == arguments[1] + end +end + +local function ref(state, arguments, level) + local level = (level or 1) + 1 + local argcnt = arguments.n + local argtype = type(arguments[1]) + local isobject = (argtype == "table" or argtype == "function" or argtype == "thread" or argtype == "userdata") + assert(argcnt > 0, s("assertion.internal.argtolittle", { "ref", 1, tostring(argcnt) }), level) + assert(isobject, s("assertion.internal.badargtype", { 1, "ref", "object", argtype }), level) + return function(value) + return value == arguments[1] + end +end + +local function is_true(state, arguments, level) + return function(value) + return value == true + end +end + +local function is_false(state, arguments, level) + return function(value) + return value == false + end +end + +local function truthy(state, arguments, level) + return function(value) + return value ~= false and value ~= nil + end +end + +local function falsy(state, arguments, level) + local is_truthy = truthy(state, arguments, level) + return function(value) + return not is_truthy(value) + end +end + +local function is_type(state, arguments, level, etype) + return function(value) + return type(value) == etype + end +end + +local function is_nil(state, arguments, level) return is_type(state, arguments, level, "nil") end +local function is_boolean(state, arguments, level) return is_type(state, arguments, level, "boolean") end +local function is_number(state, arguments, level) return is_type(state, arguments, level, "number") end +local function is_string(state, arguments, level) return is_type(state, arguments, level, "string") end +local function is_table(state, arguments, level) return is_type(state, arguments, level, "table") end +local function is_function(state, arguments, level) return is_type(state, arguments, level, "function") end +local function is_userdata(state, arguments, level) return is_type(state, arguments, level, "userdata") end +local function is_thread(state, arguments, level) return is_type(state, arguments, level, "thread") end + +assert:register("matcher", "true", is_true) +assert:register("matcher", "false", is_false) + +assert:register("matcher", "nil", is_nil) +assert:register("matcher", "boolean", is_boolean) +assert:register("matcher", "number", is_number) +assert:register("matcher", "string", is_string) +assert:register("matcher", "table", is_table) +assert:register("matcher", "function", is_function) +assert:register("matcher", "userdata", is_userdata) +assert:register("matcher", "thread", is_thread) + +assert:register("matcher", "ref", ref) +assert:register("matcher", "same", same) +assert:register("matcher", "matches", matches) +assert:register("matcher", "match", matches) +assert:register("matcher", "near", near) +assert:register("matcher", "equals", equals) +assert:register("matcher", "equal", equals) +assert:register("matcher", "unique", unique) +assert:register("matcher", "truthy", truthy) +assert:register("matcher", "falsy", falsy) diff --git a/test/integration/luassert/lua/luassert/matchers/init.lua b/test/integration/luassert/lua/luassert/matchers/init.lua new file mode 100644 index 0000000..c0ad62b --- /dev/null +++ b/test/integration/luassert/lua/luassert/matchers/init.lua @@ -0,0 +1,3 @@ +-- load basic machers +require('luassert.matchers.core') +require('luassert.matchers.composite') diff --git a/test/integration/luassert/lua/luassert/mock.lua b/test/integration/luassert/lua/luassert/mock.lua new file mode 100644 index 0000000..0a3bf3d --- /dev/null +++ b/test/integration/luassert/lua/luassert/mock.lua @@ -0,0 +1,61 @@ +-- module will return a mock module table, and will not register any assertions +local spy = require 'luassert.spy' +local stub = require 'luassert.stub' + +local function mock_apply(object, action) + if type(object) ~= "table" then return end + if spy.is_spy(object) then + return object[action](object) + end + for k,v in pairs(object) do + mock_apply(v, action) + end + return object +end + +local mock +mock = { + new = function(object, dostub, func, self, key) + local visited = {} + local function do_mock(object, self, key) + local mock_handlers = { + ["table"] = function() + if spy.is_spy(object) or visited[object] then return end + visited[object] = true + for k,v in pairs(object) do + object[k] = do_mock(v, object, k) + end + return object + end, + ["function"] = function() + if dostub then + return stub(self, key, func) + elseif self==nil then + return spy.new(object) + else + return spy.on(self, key) + end + end + } + local handler = mock_handlers[type(object)] + return handler and handler() or object + end + return do_mock(object, self, key) + end, + + clear = function(object) + return mock_apply(object, "clear") + end, + + revert = function(object) + return mock_apply(object, "revert") + end +} + +return setmetatable(mock, { + __call = function(self, ...) + -- mock originally was a function only. Now that it is a module table + -- the __call method is required for backward compatibility + return mock.new(...) + end +}) diff --git a/test/integration/luassert/lua/luassert/modifiers.lua b/test/integration/luassert/lua/luassert/modifiers.lua new file mode 100644 index 0000000..9493228 --- /dev/null +++ b/test/integration/luassert/lua/luassert/modifiers.lua @@ -0,0 +1,19 @@ +-- module will not return anything, only register assertions/modifiers with the main assert engine +local assert = require('luassert.assert') + +local function is(state) + return state +end + +local function is_not(state) + state.mod = not state.mod + return state +end + +assert:register("modifier", "is", is) +assert:register("modifier", "are", is) +assert:register("modifier", "was", is) +assert:register("modifier", "has", is) +assert:register("modifier", "does", is) +assert:register("modifier", "not", is_not) +assert:register("modifier", "no", is_not) diff --git a/test/integration/luassert/lua/luassert/namespaces.lua b/test/integration/luassert/lua/luassert/namespaces.lua new file mode 100644 index 0000000..0790fce --- /dev/null +++ b/test/integration/luassert/lua/luassert/namespaces.lua @@ -0,0 +1,2 @@ +-- stores the list of namespaces +return {} diff --git a/test/integration/luassert/lua/luassert/spy.lua b/test/integration/luassert/lua/luassert/spy.lua new file mode 100644 index 0000000..eb7fc06 --- /dev/null +++ b/test/integration/luassert/lua/luassert/spy.lua @@ -0,0 +1,195 @@ +-- module will return spy table, and register its assertions with the main assert engine +local assert = require('luassert.assert') +local util = require('luassert.util') + +-- Spy metatable +local spy_mt = { + __call = function(self, ...) + local arguments = util.make_arglist(...) + table.insert(self.calls, util.copyargs(arguments)) + local function get_returns(...) + local returnvals = util.make_arglist(...) + table.insert(self.returnvals, util.copyargs(returnvals)) + return ... + end + return get_returns(self.callback(...)) + end +} + +local spy -- must make local before defining table, because table contents refers to the table (recursion) +spy = { + new = function(callback) + callback = callback or function() end + if not util.callable(callback) then + error("Cannot spy on type '" .. type(callback) .. "', only on functions or callable elements", util.errorlevel()) + end + local s = setmetatable({ + calls = {}, + returnvals = {}, + callback = callback, + + target_table = nil, -- these will be set when using 'spy.on' + target_key = nil, + + revert = function(self) + if not self.reverted then + if self.target_table and self.target_key then + self.target_table[self.target_key] = self.callback + end + self.reverted = true + end + return self.callback + end, + + clear = function(self) + self.calls = {} + self.returnvals = {} + return self + end, + + called = function(self, times, compare) + if times or compare then + local compare = compare or function(count, expected) return count == expected end + return compare(#self.calls, times), #self.calls + end + + return (#self.calls > 0), #self.calls + end, + + called_with = function(self, args) + local last_arglist = nil + if #self.calls > 0 then + last_arglist = self.calls[#self.calls].vals + end + local matching_arglists = util.matchargs(self.calls, args) + if matching_arglists ~= nil then + return true, matching_arglists.vals + end + return false, last_arglist + end, + + returned_with = function(self, args) + local last_returnvallist = nil + if #self.returnvals > 0 then + last_returnvallist = self.returnvals[#self.returnvals].vals + end + local matching_returnvallists = util.matchargs(self.returnvals, args) + if matching_returnvallists ~= nil then + return true, matching_returnvallists.vals + end + return false, last_returnvallist + end + }, spy_mt) + assert:add_spy(s) -- register with the current state + return s + end, + + is_spy = function(object) + return type(object) == "table" and getmetatable(object) == spy_mt + end, + + on = function(target_table, target_key) + local s = spy.new(target_table[target_key]) + target_table[target_key] = s + -- store original data + s.target_table = target_table + s.target_key = target_key + + return s + end +} + +local function set_spy(state, arguments, level) + state.payload = arguments[1] + if arguments[2] ~= nil then + state.failure_message = arguments[2] + end +end + +local function returned_with(state, arguments, level) + local level = (level or 1) + 1 + local payload = rawget(state, "payload") + if payload and payload.returned_with then + local assertion_holds, matching_or_last_returnvallist = state.payload:returned_with(arguments) + local expected_returnvallist = util.shallowcopy(arguments) + util.cleararglist(arguments) + util.tinsert(arguments, 1, matching_or_last_returnvallist) + util.tinsert(arguments, 2, expected_returnvallist) + return assertion_holds + else + error("'returned_with' must be chained after 'spy(aspy)'", level) + end +end + +local function called_with(state, arguments, level) + local level = (level or 1) + 1 + local payload = rawget(state, "payload") + if payload and payload.called_with then + local assertion_holds, matching_or_last_arglist = state.payload:called_with(arguments) + local expected_arglist = util.shallowcopy(arguments) + util.cleararglist(arguments) + util.tinsert(arguments, 1, matching_or_last_arglist) + util.tinsert(arguments, 2, expected_arglist) + return assertion_holds + else + error("'called_with' must be chained after 'spy(aspy)'", level) + end +end + +local function called(state, arguments, level, compare) + local level = (level or 1) + 1 + local num_times = arguments[1] + if not num_times and not state.mod then + state.mod = true + num_times = 0 + end + local payload = rawget(state, "payload") + if payload and type(payload) == "table" and payload.called then + local result, count = state.payload:called(num_times, compare) + arguments[1] = tostring(num_times or ">0") + util.tinsert(arguments, 2, tostring(count)) + arguments.nofmt = arguments.nofmt or {} + arguments.nofmt[1] = true + arguments.nofmt[2] = true + return result + elseif payload and type(payload) == "function" then + error("When calling 'spy(aspy)', 'aspy' must not be the original function, but the spy function replacing the original", level) + else + error("'called' must be chained after 'spy(aspy)'", level) + end +end + +local function called_at_least(state, arguments, level) + local level = (level or 1) + 1 + return called(state, arguments, level, function(count, expected) return count >= expected end) +end + +local function called_at_most(state, arguments, level) + local level = (level or 1) + 1 + return called(state, arguments, level, function(count, expected) return count <= expected end) +end + +local function called_more_than(state, arguments, level) + local level = (level or 1) + 1 + return called(state, arguments, level, function(count, expected) return count > expected end) +end + +local function called_less_than(state, arguments, level) + local level = (level or 1) + 1 + return called(state, arguments, level, function(count, expected) return count < expected end) +end + +assert:register("modifier", "spy", set_spy) +assert:register("assertion", "returned_with", returned_with, "assertion.returned_with.positive", "assertion.returned_with.negative") +assert:register("assertion", "called_with", called_with, "assertion.called_with.positive", "assertion.called_with.negative") +assert:register("assertion", "called", called, "assertion.called.positive", "assertion.called.negative") +assert:register("assertion", "called_at_least", called_at_least, "assertion.called_at_least.positive", "assertion.called_less_than.positive") +assert:register("assertion", "called_at_most", called_at_most, "assertion.called_at_most.positive", "assertion.called_more_than.positive") +assert:register("assertion", "called_more_than", called_more_than, "assertion.called_more_than.positive", "assertion.called_at_most.positive") +assert:register("assertion", "called_less_than", called_less_than, "assertion.called_less_than.positive", "assertion.called_at_least.positive") + +return setmetatable(spy, { + __call = function(self, ...) + return spy.new(...) + end +}) diff --git a/test/integration/luassert/lua/luassert/state.lua b/test/integration/luassert/lua/luassert/state.lua new file mode 100644 index 0000000..6de0efe --- /dev/null +++ b/test/integration/luassert/lua/luassert/state.lua @@ -0,0 +1,127 @@ +-- maintains a state of the assert engine in a linked-list fashion +-- records; formatters, parameters, spies and stubs + +local state_mt = { + __call = function(self) + self:revert() + end +} + +local spies_mt = { __mode = "kv" } + +local nilvalue = {} -- unique ID to refer to nil values for parameters + +-- will hold the current state +local current + +-- exported module table +local state = {} + +------------------------------------------------------ +-- Reverts to a (specific) snapshot. +-- @param self (optional) the snapshot to revert to. If not provided, it will revert to the last snapshot. +state.revert = function(self) + if not self then + -- no snapshot given, so move 1 up + self = current + if not self.previous then + -- top of list, no previous one, nothing to do + return + end + end + if getmetatable(self) ~= state_mt then error("Value provided is not a valid snapshot", 2) end + + if self.next then + self.next:revert() + end + -- revert formatters in 'last' + self.formatters = {} + -- revert parameters in 'last' + self.parameters = {} + -- revert spies/stubs in 'last' + for s,_ in pairs(self.spies) do + self.spies[s] = nil + s:revert() + end + setmetatable(self, nil) -- invalidate as a snapshot + current = self.previous + current.next = nil +end + +------------------------------------------------------ +-- Creates a new snapshot. +-- @return snapshot table +state.snapshot = function() + local new = setmetatable ({ + formatters = {}, + parameters = {}, + spies = setmetatable({}, spies_mt), + previous = current, + revert = state.revert, + }, state_mt) + if current then current.next = new end + current = new + return current +end + + +-- FORMATTERS +state.add_formatter = function(callback) + table.insert(current.formatters, 1, callback) +end + +state.remove_formatter = function(callback, s) + s = s or current + for i, v in ipairs(s.formatters) do + if v == callback then + table.remove(s.formatters, i) + break + end + end + -- wasn't found, so traverse up 1 state + if s.previous then + state.remove_formatter(callback, s.previous) + end +end + +state.format_argument = function(val, s, fmtargs) + s = s or current + for _, fmt in ipairs(s.formatters) do + local valfmt = fmt(val, fmtargs) + if valfmt ~= nil then return valfmt end + end + -- nothing found, check snapshot 1 up in list + if s.previous then + return state.format_argument(val, s.previous, fmtargs) + end + return nil -- end of list, couldn't format +end + + +-- PARAMETERS +state.set_parameter = function(name, value) + if value == nil then value = nilvalue end + current.parameters[name] = value +end + +state.get_parameter = function(name, s) + s = s or current + local val = s.parameters[name] + if val == nil and s.previous then + -- not found, so check 1 up in list + return state.get_parameter(name, s.previous) + end + if val ~= nilvalue then + return val + end + return nil +end + +-- SPIES / STUBS +state.add_spy = function(spy) + current.spies[spy] = true +end + +state.snapshot() -- create initial state + +return state diff --git a/test/integration/luassert/lua/luassert/stub.lua b/test/integration/luassert/lua/luassert/stub.lua new file mode 100644 index 0000000..91ae6e0 --- /dev/null +++ b/test/integration/luassert/lua/luassert/stub.lua @@ -0,0 +1,107 @@ +-- module will return a stub module table +local assert = require 'luassert.assert' +local spy = require 'luassert.spy' +local util = require 'luassert.util' +local unpack = util.unpack +local pack = util.pack + +local stub = {} + +function stub.new(object, key, ...) + if object == nil and key == nil then + -- called without arguments, create a 'blank' stub + object = {} + key = "" + end + local return_values = pack(...) + assert(type(object) == "table" and key ~= nil, "stub.new(): Can only create stub on a table key, call with 2 params; table, key", util.errorlevel()) + assert(object[key] == nil or util.callable(object[key]), "stub.new(): The element for which to create a stub must either be callable, or be nil", util.errorlevel()) + local old_elem = object[key] -- keep existing element (might be nil!) + + local fn = (return_values.n == 1 and util.callable(return_values[1]) and return_values[1]) + local defaultfunc = fn or function() + return unpack(return_values) + end + local oncalls = {} + local callbacks = {} + local stubfunc = function(...) + local args = util.make_arglist(...) + local match = util.matchoncalls(oncalls, args) + if match then + return callbacks[match](...) + end + return defaultfunc(...) + end + + object[key] = stubfunc -- set the stubfunction + local s = spy.on(object, key) -- create a spy on top of the stub function + local spy_revert = s.revert -- keep created revert function + + s.revert = function(self) -- wrap revert function to restore original element + if not self.reverted then + spy_revert(self) + object[key] = old_elem + self.reverted = true + end + return old_elem + end + + s.returns = function(...) + local return_args = pack(...) + defaultfunc = function() + return unpack(return_args) + end + return s + end + + s.invokes = function(func) + defaultfunc = function(...) + return func(...) + end + return s + end + + s.by_default = { + returns = s.returns, + invokes = s.invokes, + } + + s.on_call_with = function(...) + local match_args = util.make_arglist(...) + match_args = util.copyargs(match_args) + return { + returns = function(...) + local return_args = pack(...) + table.insert(oncalls, match_args) + callbacks[match_args] = function() + return unpack(return_args) + end + return s + end, + invokes = function(func) + table.insert(oncalls, match_args) + callbacks[match_args] = function(...) + return func(...) + end + return s + end + } + end + + return s +end + +local function set_stub(state, arguments) + state.payload = arguments[1] + state.failure_message = arguments[2] +end + +assert:register("modifier", "stub", set_stub) + +return setmetatable(stub, { + __call = function(self, ...) + -- stub originally was a function only. Now that it is a module table + -- the __call method is required for backward compatibility + return stub.new(...) + end +}) diff --git a/test/integration/luassert/lua/luassert/util.lua b/test/integration/luassert/lua/luassert/util.lua new file mode 100644 index 0000000..da2f247 --- /dev/null +++ b/test/integration/luassert/lua/luassert/util.lua @@ -0,0 +1,362 @@ +local util = {} +local arglist_mt = {} + +-- have pack/unpack both respect the 'n' field +local _unpack = table.unpack or unpack +local unpack = function(t, i, j) return _unpack(t, i or 1, j or t.n or #t) end +local pack = function(...) return { n = select("#", ...), ... } end +util.pack = pack +util.unpack = unpack + + +function util.deepcompare(t1,t2,ignore_mt,cycles,thresh1,thresh2) + local ty1 = type(t1) + local ty2 = type(t2) + -- non-table types can be directly compared + if ty1 ~= 'table' or ty2 ~= 'table' then return t1 == t2 end + local mt1 = debug.getmetatable(t1) + local mt2 = debug.getmetatable(t2) + -- would equality be determined by metatable __eq? + if mt1 and mt1 == mt2 and mt1.__eq then + -- then use that unless asked not to + if not ignore_mt then return t1 == t2 end + else -- we can skip the deep comparison below if t1 and t2 share identity + if rawequal(t1, t2) then return true end + end + + -- handle recursive tables + cycles = cycles or {{},{}} + thresh1, thresh2 = (thresh1 or 1), (thresh2 or 1) + cycles[1][t1] = (cycles[1][t1] or 0) + cycles[2][t2] = (cycles[2][t2] or 0) + if cycles[1][t1] == 1 or cycles[2][t2] == 1 then + thresh1 = cycles[1][t1] + 1 + thresh2 = cycles[2][t2] + 1 + end + if cycles[1][t1] > thresh1 and cycles[2][t2] > thresh2 then + return true + end + + cycles[1][t1] = cycles[1][t1] + 1 + cycles[2][t2] = cycles[2][t2] + 1 + + for k1,v1 in next, t1 do + local v2 = t2[k1] + if v2 == nil then + return false, {k1} + end + + local same, crumbs = util.deepcompare(v1,v2,nil,cycles,thresh1,thresh2) + if not same then + crumbs = crumbs or {} + table.insert(crumbs, k1) + return false, crumbs + end + end + for k2,_ in next, t2 do + -- only check whether each element has a t1 counterpart, actual comparison + -- has been done in first loop above + if t1[k2] == nil then return false, {k2} end + end + + cycles[1][t1] = cycles[1][t1] - 1 + cycles[2][t2] = cycles[2][t2] - 1 + + return true +end + +function util.shallowcopy(t) + if type(t) ~= "table" then return t end + local copy = {} + setmetatable(copy, getmetatable(t)) + for k,v in next, t do + copy[k] = v + end + return copy +end + +function util.deepcopy(t, deepmt, cache) + local spy = require 'luassert.spy' + if type(t) ~= "table" then return t end + local copy = {} + + -- handle recursive tables + local cache = cache or {} + if cache[t] then return cache[t] end + cache[t] = copy + + for k,v in next, t do + copy[k] = (spy.is_spy(v) and v or util.deepcopy(v, deepmt, cache)) + end + if deepmt then + debug.setmetatable(copy, util.deepcopy(debug.getmetatable(t), false, cache)) + else + debug.setmetatable(copy, debug.getmetatable(t)) + end + return copy +end + +----------------------------------------------- +-- Copies arguments as a list of arguments +-- @param args the arguments of which to copy +-- @return the copy of the arguments +function util.copyargs(args) + local copy = {} + setmetatable(copy, getmetatable(args)) + local match = require 'luassert.match' + local spy = require 'luassert.spy' + for k,v in pairs(args) do + copy[k] = ((match.is_matcher(v) or spy.is_spy(v)) and v or util.deepcopy(v)) + end + return { vals = copy, refs = util.shallowcopy(args) } +end + +----------------------------------------------- +-- Clear an arguments or return values list from a table +-- @param arglist the table to clear of arguments or return values and their count +-- @return No return values +function util.cleararglist(arglist) + for idx = arglist.n, 1, -1 do + util.tremove(arglist, idx) + end + arglist.n = nil +end + +----------------------------------------------- +-- Test specs against an arglist in deepcopy and refs flavours. +-- @param args deepcopy arglist +-- @param argsrefs refs arglist +-- @param specs arguments/return values to match against args/argsrefs +-- @return true if specs match args/argsrefs, false otherwise +local function matcharg(args, argrefs, specs) + local match = require 'luassert.match' + for idx, argval in pairs(args) do + local spec = specs[idx] + if match.is_matcher(spec) then + if match.is_ref_matcher(spec) then + argval = argrefs[idx] + end + if not spec(argval) then + return false + end + elseif (spec == nil or not util.deepcompare(argval, spec)) then + return false + end + end + + for idx, spec in pairs(specs) do + -- only check whether each element has an args counterpart, + -- actual comparison has been done in first loop above + local argval = args[idx] + if argval == nil then + -- no args counterpart, so try to compare using matcher + if match.is_matcher(spec) then + if not spec(argval) then + return false + end + else + return false + end + end + end + return true +end + +----------------------------------------------- +-- Find matching arguments/return values in a saved list of +-- arguments/returned values. +-- @param invocations_list list of arguments/returned values to search (list of lists) +-- @param specs arguments/return values to match against argslist +-- @return the last matching arguments/returned values if a match is found, otherwise nil +function util.matchargs(invocations_list, specs) + -- Search the arguments/returned values last to first to give the + -- most helpful answer possible. In the cases where you can place + -- your assertions between calls to check this gives you the best + -- information if no calls match. In the cases where you can't do + -- that there is no good way to predict what would work best. + assert(not util.is_arglist(invocations_list), "expected a list of arglist-object, got an arglist") + for ii = #invocations_list, 1, -1 do + local val = invocations_list[ii] + if matcharg(val.vals, val.refs, specs) then + return val + end + end + return nil +end + +----------------------------------------------- +-- Find matching oncall for an actual call. +-- @param oncalls list of oncalls to search +-- @param args actual call argslist to match against +-- @return the first matching oncall if a match is found, otherwise nil +function util.matchoncalls(oncalls, args) + for _, callspecs in ipairs(oncalls) do + -- This lookup is done immediately on *args* passing into the stub + -- so pass *args* as both *args* and *argsref* without copying + -- either. + if matcharg(args, args, callspecs.vals) then + return callspecs + end + end + return nil +end + +----------------------------------------------- +-- table.insert() replacement that respects nil values. +-- The function will use table field 'n' as indicator of the +-- table length, if not set, it will be added. +-- @param t table into which to insert +-- @param pos (optional) position in table where to insert. NOTE: not optional if you want to insert a nil-value! +-- @param val value to insert +-- @return No return values +function util.tinsert(...) + -- check optional POS value + local args = {...} + local c = select('#',...) + local t = args[1] + local pos = args[2] + local val = args[3] + if c < 3 then + val = pos + pos = nil + end + -- set length indicator n if not present (+1) + t.n = (t.n or #t) + 1 + if not pos then + pos = t.n + elseif pos > t.n then + -- out of our range + t[pos] = val + t.n = pos + end + -- shift everything up 1 pos + for i = t.n, pos + 1, -1 do + t[i]=t[i-1] + end + -- add element to be inserted + t[pos] = val +end +----------------------------------------------- +-- table.remove() replacement that respects nil values. +-- The function will use table field 'n' as indicator of the +-- table length, if not set, it will be added. +-- @param t table from which to remove +-- @param pos (optional) position in table to remove +-- @return No return values +function util.tremove(t, pos) + -- set length indicator n if not present (+1) + t.n = t.n or #t + if not pos then + pos = t.n + elseif pos > t.n then + local removed = t[pos] + -- out of our range + t[pos] = nil + return removed + end + local removed = t[pos] + -- shift everything up 1 pos + for i = pos, t.n do + t[i]=t[i+1] + end + -- set size, clean last + t[t.n] = nil + t.n = t.n - 1 + return removed +end + +----------------------------------------------- +-- Checks an element to be callable. +-- The type must either be a function or have a metatable +-- containing an '__call' function. +-- @param object element to inspect on being callable or not +-- @return boolean, true if the object is callable +function util.callable(object) + return type(object) == "function" or type((debug.getmetatable(object) or {}).__call) == "function" +end + +----------------------------------------------- +-- Checks an element has tostring. +-- The type must either be a string or have a metatable +-- containing an '__tostring' function. +-- @param object element to inspect on having tostring or not +-- @return boolean, true if the object has tostring +function util.hastostring(object) + return type(object) == "string" or type((debug.getmetatable(object) or {}).__tostring) == "function" +end + +----------------------------------------------- +-- Find the first level, not defined in the same file as the caller's +-- code file to properly report an error. +-- @param level the level to use as the caller's source file +-- @return number, the level of which to report an error +function util.errorlevel(level) + local level = (level or 1) + 1 -- add one to get level of the caller + local info = debug.getinfo(level) + local source = (info or {}).source + local file = source + while file and (file == source or source == "=(tail call)") do + level = level + 1 + info = debug.getinfo(level) + source = (info or {}).source + end + if level > 1 then level = level - 1 end -- deduct call to errorlevel() itself + return level +end + +----------------------------------------------- +-- Extract modifier and namespace keys from list of tokens. +-- @param nspace the namespace from which to match tokens +-- @param tokens list of tokens to search for keys +-- @return table, list of keys that were extracted +function util.extract_keys(nspace, tokens) + local namespace = require 'luassert.namespaces' + + -- find valid keys by coalescing tokens as needed, starting from the end + local keys = {} + local key = nil + local i = #tokens + while i > 0 do + local token = tokens[i] + key = key and (token .. '_' .. key) or token + + -- find longest matching key in the given namespace + local longkey = i > 1 and (tokens[i-1] .. '_' .. key) or nil + while i > 1 and longkey and namespace[nspace][longkey] do + key = longkey + i = i - 1 + token = tokens[i] + longkey = (token .. '_' .. key) + end + + if namespace.modifier[key] or namespace[nspace][key] then + table.insert(keys, 1, key) + key = nil + end + i = i - 1 + end + + -- if there's anything left we didn't recognize it + if key then + error("luassert: unknown modifier/" .. nspace .. ": '" .. key .."'", util.errorlevel(2)) + end + + return keys +end + +----------------------------------------------- +-- store argument list for return values of a function in a table. +-- The table will get a metatable to identify it as an arglist +function util.make_arglist(...) + local arglist = { ... } + arglist.n = select('#', ...) -- add values count for trailing nils + return setmetatable(arglist, arglist_mt) +end + +----------------------------------------------- +-- check a table to be an arglist type. +function util.is_arglist(object) + return getmetatable(object) == arglist_mt +end + +return util diff --git a/test/integration/luassert/lua/say/LICENSE b/test/integration/luassert/lua/say/LICENSE new file mode 100644 index 0000000..47cc42a --- /dev/null +++ b/test/integration/luassert/lua/say/LICENSE @@ -0,0 +1,22 @@ +MIT License Terms +================= + +Copyright (c) 2012 Olivine Labs, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/test/integration/luassert/lua/say/init.lua b/test/integration/luassert/lua/say/init.lua new file mode 100644 index 0000000..73cb505 --- /dev/null +++ b/test/integration/luassert/lua/say/init.lua @@ -0,0 +1,65 @@ +local unpack = table.unpack or unpack + +local registry = { } +local current_namespace +local fallback_namespace + +local s = { + + _COPYRIGHT = "Copyright (c) 2012 Olivine Labs, LLC.", + _DESCRIPTION = "A simple string key/value store for i18n or any other case where you want namespaced strings.", + _VERSION = "Say 1.3", + + set_namespace = function(self, namespace) + current_namespace = namespace + if not registry[current_namespace] then + registry[current_namespace] = {} + end + end, + + set_fallback = function(self, namespace) + fallback_namespace = namespace + if not registry[fallback_namespace] then + registry[fallback_namespace] = {} + end + end, + + set = function(self, key, value) + registry[current_namespace][key] = value + end +} + +local __meta = { + __call = function(self, key, vars) + if vars ~= nil and type(vars) ~= "table" then + error(("expected parameter table to be a table, got '%s'"):format(type(vars)), 2) + end + vars = vars or {} + vars.n = math.max((vars.n or 0), #vars) + + local str = registry[current_namespace][key] or registry[fallback_namespace][key] + + if str == nil then + return nil + end + str = tostring(str) + local strings = {} + + for i = 1, vars.n or #vars do + table.insert(strings, tostring(vars[i])) + end + + return #strings > 0 and str:format(unpack(strings)) or str + end, + + __index = function(self, key) + return registry[key] + end +} + +s:set_fallback('en') +s:set_namespace('en') + +s._registry = registry + +return setmetatable(s, __meta) diff --git a/test/integration/luassert_test.exs b/test/integration/luassert_test.exs new file mode 100644 index 0000000..6339ad9 --- /dev/null +++ b/test/integration/luassert_test.exs @@ -0,0 +1,113 @@ +defmodule Lua.Integration.LuassertTest do + use ExUnit.Case, async: true + + # End-to-end regression coverage for the require pipeline against a + # real-world Lua library. luassert + say exercises: + # + # * Multi-level require chains (luassert → luassert.assertions → + # luassert.assert → say). + # * Modules with 50+ top-level local-function definitions that close + # over top-level locals — the exact shape that surfaced the + # open_upvalues leak in issue #244. + # * Modules that return tables vs. modules that only register and + # return nothing (cached as the `true` sentinel). + # * `setmetatable` on returned values, `__call` and `__index` + # metamethods. + # + # The vendored source under `test/integration/luassert/lua/` is pinned + # to luassert v1.9.0 and say v1.4.1. See the README in that directory + # for licensing and update instructions. + + @lua_dir Path.expand("luassert/lua", __DIR__) + + defp new_lua do + [exclude: [[:require], [:package]]] + |> Lua.new() + |> Lua.set_lua_paths([ + Path.join(@lua_dir, "?.lua"), + Path.join(@lua_dir, "?/init.lua") + ]) + end + + # Modules that load cleanly under the sandboxed VM. The omitted ones + # (`luassert.formatters`, top-level `luassert`) depend on + # `io.type(io.stdout)` for TTY detection at module-load time, which + # this VM intentionally does not expose; that gap is orthogonal to + # issue #244 and tracked separately. + @loadable_modules ~w[ + luassert.assert + luassert.assertions + luassert.modifiers + luassert.array + luassert.spy + luassert.stub + luassert.mock + luassert.match + luassert.state + luassert.util + luassert.namespaces + luassert.compatibility + luassert.matchers + luassert.languages.en + say + ] + + describe "individual luassert modules load via require" do + for modname <- @loadable_modules do + test "require('#{modname}') returns a value without raising" do + # `assertions`/`modifiers`/`array`/etc. don't `return` anything, + # so they cache as the `true` sentinel. Modules that return a + # table cache as the table. Both shapes are valid Lua. + code = "local m = require('#{unquote(modname)}'); return type(m)" + {[type_str], _lua} = Lua.eval!(new_lua(), code) + assert type_str in ["table", "boolean"] + end + end + end + + describe "luassert.assert API survives the require pipeline" do + test "obj table exposes register/snapshot/level/format functions" do + # The smoke test for issue #244: load `luassert.assert` and + # confirm the obj table's documented fields survived the require + # pipeline. Without the fix, the local `assert` would alias to a + # leaked inner upvalue cell and lose its fields. + code = ~S""" + local assert = require('luassert.assert') + return type(assert), type(assert.register), type(assert.snapshot), + type(assert.level), type(assert.format) + """ + + {types, _lua} = Lua.eval!(new_lua(), code) + assert types == ["table", "function", "function", "function", "function"] + end + + test "assertions module registers without raising" do + # The exact failure path from issue #244: + # `local assert = require('luassert.assert')` at the top of + # `luassert/assertions.lua`, followed by many `local function` + # defs, followed by `assert:register('modifier', 'message', …)` + # at line 307. Loading `luassert.assertions` succeeds iff every + # one of those `assert:register(...)` calls finds the obj table + # at the captured upvalue. + code = ~S""" + require('luassert.assertions') + return 'ok' + """ + + assert {["ok"], _lua} = Lua.eval!(new_lua(), code) + end + + test "array and spy modules (other failure sites from issue #244)" do + # `luassert.array:66` and `luassert.spy:182` both have the same + # shape as assertions.lua and were the other reproducers in the + # bug report. + code = ~S""" + require('luassert.array') + require('luassert.spy') + return 'ok' + """ + + assert {["ok"], _lua} = Lua.eval!(new_lua(), code) + end + end +end diff --git a/test/lua/vm/require_open_upvalue_test.exs b/test/lua/vm/require_open_upvalue_test.exs new file mode 100644 index 0000000..f92ef30 --- /dev/null +++ b/test/lua/vm/require_open_upvalue_test.exs @@ -0,0 +1,91 @@ +defmodule Lua.VM.RequireOpenUpvalueTest do + use ExUnit.Case, async: true + + # Pins the invariant that `require` does not leak the inner module's + # `state.open_upvalues` entries back to the outer caller. + # + # The inner module's closures populate `open_upvalues` keyed by the + # inner's register index. Without a save/restore around the nested + # execution, the outer caller inherits those entries and any later + # closure in the outer that captures a local at the *same register + # index* would reuse the inner's stale cell — silently aliasing the + # outer's local to whatever the inner had at that register. + + setup do + tmp_dir = Path.join(System.tmp_dir!(), "lua_require_upvalue_test_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp_dir) + on_exit(fn -> File.rm_rf!(tmp_dir) end) + %{tmp_dir: tmp_dir} + end + + defp eval_with_path(code, tmp_dir) do + lua = Lua.new(exclude: [[:package], [:require]]) + lua = Lua.set_lua_paths(lua, [Path.join(tmp_dir, "?.lua")]) + Lua.eval!(lua, code) + end + + test "outer module's local is not aliased to inner module's upvalue cell", %{tmp_dir: tmp_dir} do + # Inner module: top-level local at reg 0 with a string value, captured + # by a closure (which populates open_upvalues[0] in the inner's + # execution). + File.write!(Path.join(tmp_dir, "inner.lua"), """ + local inner_local = "inner_value" + + local function captures_it() + return inner_local + end + + return { tag = "inner", fn = captures_it } + """) + + # Outer chunk: requires inner (so the inner's body runs and leaves + # open_upvalues entries on the state), then defines a closure + # capturing its own top-level local at reg 0. With the bug, the + # outer closure would reuse the inner's stale cell holding the + # string "inner_value"; the assertion below would then read + # "inner_value" instead of the module table's tag. + code = ~S""" + local m = require("inner") + + local function captures_m() + return m + end + + return m.tag, captures_m().tag + """ + + assert {["inner", "inner"], _} = eval_with_path(code, tmp_dir) + end + + test "outer local survives many local function definitions after require", %{tmp_dir: tmp_dir} do + # Mirrors the luassert.assertions shape: a module declares + # `local assert = require('inner')`, then defines many local + # functions that close over `assert`, then calls a method on + # `assert`. The first method call must see the actual obj table, + # not a stale upvalue from the inner module. + File.write!(Path.join(tmp_dir, "inner.lua"), """ + local s = "inner_s" + + local function uses_s() + return s + end + + local obj = { register = function(self, name) return "registered:" .. name end } + return obj + """) + + code = ~S""" + local assert_obj = require("inner") + + local function noop_1() return assert_obj end + local function noop_2() return assert_obj end + local function noop_3() return assert_obj end + local function noop_4() return assert_obj end + local function noop_5() return assert_obj end + + return assert_obj:register("ok") + """ + + assert {["registered:ok"], _} = eval_with_path(code, tmp_dir) + end +end