From fd43fa479e1080d46b93db82dfd1e02562baf0b3 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Tue, 26 May 2026 18:38:38 -0700 Subject: [PATCH 1/3] chore(A39): start plan --- .../plans/A39-require-leaks-open-upvalues.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 .agents/plans/A39-require-leaks-open-upvalues.md 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..aaabe19 --- /dev/null +++ b/.agents/plans/A39-require-leaks-open-upvalues.md @@ -0,0 +1,156 @@ +--- +id: A39 +title: require() leaks inner module's open_upvalues into outer caller +issue: 244 +pr: null +branch: fix/require-leaks-open-upvalues +base: main +status: in-progress +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. From e0585dc1c19ac40fb18d60d41d127b2d1df5b2f2 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Tue, 26 May 2026 18:44:21 -0700 Subject: [PATCH 2/3] fix(vm): restore caller's open_upvalues after nested execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Lua.VM.Executor.execute/5` reset `state.open_upvalues` to `%{}` at entry but never restored the caller's map on return. When a Lua chunk called `require`, the inner module's body would populate `open_upvalues` with cells keyed by the inner's register indices, and those entries would leak back to the outer caller. The outer caller's later closures would then reuse the stale cells by register index, aliasing the outer's locals to unrelated inner values. This broke real-world Lua libraries (luassert.assertions, luassert.array, luassert.spy) that follow the pattern `local x = require(...)` → many `local function` defs that close over `x` → `x:method(...)`: by the time `x:method` ran, reads of `x` went through a stale upvalue cell and saw an inner module's local instead. As a side effect, `Lua.call_function/3` (public API) now preserves the caller's `open_upvalues` across calls — previously the same leak applied there too, but no caller of the public API depended on the buggy behaviour. Two layers of test coverage: - Unit regression in `test/lua/vm/require_open_upvalue_test.exs` with a minimal two-file pure-Lua repro. - Integration test that vendors luassert v1.9.0 + say v1.4.1 under `test/integration/luassert/` and asserts every interior luassert module loads via `require` without raising. luassert is the dominant testing framework in the Lua ecosystem and was the original reproducer in the bug report. Plan: A39 Closes #244 --- CHANGELOG.md | 10 + lib/lua/vm/executor.ex | 15 +- test/integration/luassert/README.md | 86 +++++ .../integration/luassert/lua/luassert/LICENSE | 22 ++ .../luassert/lua/luassert/array.lua | 70 ++++ .../luassert/lua/luassert/assert.lua | 180 +++++++++ .../luassert/lua/luassert/assertions.lua | 334 ++++++++++++++++ .../luassert/lua/luassert/compatibility.lua | 9 + .../lua/luassert/formatters/binarystring.lua | 28 ++ .../luassert/lua/luassert/formatters/init.lua | 255 ++++++++++++ .../luassert/lua/luassert/init.lua | 17 + .../luassert/lua/luassert/languages/ar.lua | 21 + .../luassert/lua/luassert/languages/de.lua | 48 +++ .../luassert/lua/luassert/languages/en.lua | 48 +++ .../luassert/lua/luassert/languages/fr.lua | 50 +++ .../luassert/lua/luassert/languages/is.lua | 48 +++ .../luassert/lua/luassert/languages/ja.lua | 34 ++ .../luassert/lua/luassert/languages/nl.lua | 25 ++ .../luassert/lua/luassert/languages/ru.lua | 21 + .../luassert/lua/luassert/languages/ua.lua | 21 + .../luassert/lua/luassert/languages/zh.lua | 31 ++ .../luassert/lua/luassert/match.lua | 79 ++++ .../lua/luassert/matchers/composite.lua | 61 +++ .../luassert/lua/luassert/matchers/core.lua | 173 +++++++++ .../luassert/lua/luassert/matchers/init.lua | 3 + .../luassert/lua/luassert/mock.lua | 61 +++ .../luassert/lua/luassert/modifiers.lua | 19 + .../luassert/lua/luassert/namespaces.lua | 2 + .../integration/luassert/lua/luassert/spy.lua | 195 ++++++++++ .../luassert/lua/luassert/state.lua | 127 ++++++ .../luassert/lua/luassert/stub.lua | 107 ++++++ .../luassert/lua/luassert/util.lua | 362 ++++++++++++++++++ test/integration/luassert/lua/say/LICENSE | 22 ++ test/integration/luassert/lua/say/init.lua | 65 ++++ test/integration/luassert_test.exs | 113 ++++++ test/lua/vm/require_open_upvalue_test.exs | 91 +++++ 36 files changed, 2852 insertions(+), 1 deletion(-) create mode 100644 test/integration/luassert/README.md create mode 100644 test/integration/luassert/lua/luassert/LICENSE create mode 100644 test/integration/luassert/lua/luassert/array.lua create mode 100644 test/integration/luassert/lua/luassert/assert.lua create mode 100644 test/integration/luassert/lua/luassert/assertions.lua create mode 100644 test/integration/luassert/lua/luassert/compatibility.lua create mode 100644 test/integration/luassert/lua/luassert/formatters/binarystring.lua create mode 100644 test/integration/luassert/lua/luassert/formatters/init.lua create mode 100644 test/integration/luassert/lua/luassert/init.lua create mode 100644 test/integration/luassert/lua/luassert/languages/ar.lua create mode 100644 test/integration/luassert/lua/luassert/languages/de.lua create mode 100644 test/integration/luassert/lua/luassert/languages/en.lua create mode 100644 test/integration/luassert/lua/luassert/languages/fr.lua create mode 100644 test/integration/luassert/lua/luassert/languages/is.lua create mode 100644 test/integration/luassert/lua/luassert/languages/ja.lua create mode 100644 test/integration/luassert/lua/luassert/languages/nl.lua create mode 100644 test/integration/luassert/lua/luassert/languages/ru.lua create mode 100644 test/integration/luassert/lua/luassert/languages/ua.lua create mode 100644 test/integration/luassert/lua/luassert/languages/zh.lua create mode 100644 test/integration/luassert/lua/luassert/match.lua create mode 100644 test/integration/luassert/lua/luassert/matchers/composite.lua create mode 100644 test/integration/luassert/lua/luassert/matchers/core.lua create mode 100644 test/integration/luassert/lua/luassert/matchers/init.lua create mode 100644 test/integration/luassert/lua/luassert/mock.lua create mode 100644 test/integration/luassert/lua/luassert/modifiers.lua create mode 100644 test/integration/luassert/lua/luassert/namespaces.lua create mode 100644 test/integration/luassert/lua/luassert/spy.lua create mode 100644 test/integration/luassert/lua/luassert/state.lua create mode 100644 test/integration/luassert/lua/luassert/stub.lua create mode 100644 test/integration/luassert/lua/luassert/util.lua create mode 100644 test/integration/luassert/lua/say/LICENSE create mode 100644 test/integration/luassert/lua/say/init.lua create mode 100644 test/integration/luassert_test.exs create mode 100644 test/lua/vm/require_open_upvalue_test.exs 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 From 20de218aa58fb36d19a02fe64e59d9747d33f4bf Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Tue, 26 May 2026 18:47:02 -0700 Subject: [PATCH 3/3] chore(A39): mark plan as review, record PR #245 --- .../plans/A39-require-leaks-open-upvalues.md | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/.agents/plans/A39-require-leaks-open-upvalues.md b/.agents/plans/A39-require-leaks-open-upvalues.md index aaabe19..aef0505 100644 --- a/.agents/plans/A39-require-leaks-open-upvalues.md +++ b/.agents/plans/A39-require-leaks-open-upvalues.md @@ -2,10 +2,10 @@ id: A39 title: require() leaks inner module's open_upvalues into outer caller issue: 244 -pr: null +pr: 245 branch: fix/require-leaks-open-upvalues base: main -status: in-progress +status: review direction: A unlocks: - luassert.assertions @@ -154,3 +154,32 @@ Suite count before this plan: 1772 passing, 0 failing, 30 skipped. 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).