Files
benilla-octo/crates/benilla-ui/src/script/tests/stdlib.rs
T
SamandClaude Fable 5.1 a753a8b55d an addon's font draws in its own face, and Bartender2 keeps its profile
- text shapes in the face an addon asked for: MikScrollingBattleText draws in its own Porky instead of a system sans, and Arial Narrow and Friz Quadrata are no longer drawn by the Mac's own fonts (2123)
- Bartender2 and Cartographer stop resetting their profile at every login: a stack trace opens on the caller, where AceDB reads its addon's folder; a font's reported height is a number before its face resolves, which Tablet-2.0 does arithmetic on (2121, 2129)
- a button highlight built from a texture object draws, so Bongos' bar-drag button, _Nameplates' mouseover glow and Quiver's option rows show theirs; clearing a state texture removes it instead of drawing it in every state (2124)
- a toc's repeated SavedVariables lines add up, so all 25 of CT_RaidAssist's saved globals persist instead of seven; colour-mapped TGA icons decode, so FonzAppraiser's and FuBar_WeaponRebuffFu's draw (2126, 2128)
- MikScrollingBattleText's DoT lines are back with their school tints, and the school word is capitalised; a chat line with no colour stays white, and the short colour-id form of adding a message works (2127, 2125)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 20:50:59 +07:00

438 lines
19 KiB
Rust

//! The WoW stdlib: positional `format`, the `getglobal`/`strsplit`/`wipe` alias layer, and the
//! sandbox holes (`loadstring` text-only, dangerous globals removed).
use super::common::script;
// ── The positional format wrapper ───────────────────────────────────────────────────────────────
#[test]
fn positional_format_reorders_and_mix_is_an_error() {
let s = script();
assert_eq!(
s.eval::<String>(r#"return format("%2$s %1$s", "a", "b")"#)
.unwrap(),
"b a"
);
// width/precision travel with the positional spec
assert_eq!(
s.eval::<String>(r#"return format("%1$05d", 42)"#).unwrap(),
"00042"
);
// sequential still works (and via string.format too, which we patched)
assert_eq!(
s.eval::<String>(r#"return string.format("%d-%s", 1, "x")"#)
.unwrap(),
"1-x"
);
// %% is preserved
assert_eq!(
s.eval::<String>(r#"return format("%1$d%%", 50)"#).unwrap(),
"50%"
);
// mixing positional and sequential is an error (matches Blizzard erroring)
let mixed_ok: bool = s
.eval(r#"return pcall(format, "%1$s %s", "a", "b")"#)
.unwrap();
assert!(!mixed_ok, "mixed positional+sequential must error");
}
// ── getglobal / strsplit / wipe / the alias layer ───────────────────────────────────────────────
#[test]
fn stdlib_aliases_and_helpers() {
let s = script();
s.run(
r#"
-- getglobal on a named frame
local f = CreateFrame("Frame", "GG")
assert(getglobal("GG") == f)
-- strsplit returns pieces (empty fields preserved)
local a, b, c = strsplit(",", "x,y,z")
assert(a == "x" and b == "y" and c == "z")
local e1, e2 = strsplit(",", ",tail")
assert(e1 == "" and e2 == "tail")
-- strjoin / strconcat / strtrim
assert(strjoin("-", "a", "b", "c") == "a-b-c")
assert(strconcat("a", "b", "c") == "abc")
assert(strtrim(" hi \t") == "hi")
-- the bare-global aliases
assert(strupper("ab") == "AB" and strlower("AB") == "ab")
assert(strsub("hello", 2, 3) == "el")
assert(strlen("hello") == 5)
local t = {}
tinsert(t, 10); tinsert(t, 20)
assert(getn(t) == 2)
tremove(t, 1)
assert(t[1] == 20)
-- wipe empties a table in place
local w = { 1, 2, x = 3 }
assert(wipe(w) == w and next(w) == nil)
-- tostringall
local s1, s2 = tostringall(1, true)
assert(s1 == "1" and s2 == "true")
"#,
)
.unwrap();
}
// ── Sandbox holes ───────────────────────────────────────────────────────────────────────────────
#[test]
fn sandbox_removes_dangerous_globals() {
let s = script();
let all_nil: bool = s
.eval(
r#"return io == nil and os == nil and package == nil and require == nil
and dofile == nil and loadfile == nil and debug == nil"#,
)
.unwrap();
assert!(all_nil);
// `debugstack` survives the sandbox — and returns a REAL traceback, not the `""` this used to
// assert. The stub was fine for addons that only DISPLAY it and wrong for the ones that PARSE
// it: `FuBarPlugin-2.0.lua:752` finds each plugin's own folder in `debugstack(6, 1, 0)`, and
// against `""` that returned nil and killed 20 corpus addons.
let trace = s.eval::<String>("return debugstack()").unwrap();
assert!(
!trace.is_empty(),
"debugstack must return a real traceback: {trace:?}"
);
// **Frames only — no `stack traceback:` header** (decision 2121). Line 1 IS a frame, which is
// what `AceLibrary.lua:70` reads (`string.gsub(stack, "\n.*", "")` then
// `".*\\(.*).lua:%d+: .*"`), and what `AceDB-2.0.lua:742` counts on when it skips exactly one
// line to reach its caller's.
assert!(
!trace.starts_with("stack traceback"),
"the reference has no header line: {trace:?}"
);
// A level far past the top of the stack is an empty string, never a raise — a caller that
// guesses too deep still gets something it can `string.find` against.
assert_eq!(s.eval::<String>("return debugstack(99)").unwrap(), "");
}
/// **AceDB-2.0's own capture, run for real across two chunks** (decision 2121).
///
/// `RegisterDB` reads the calling addon's folder out of `debugstack()` by skipping exactly one
/// line — its own frame — and taking the `\AddOns\<folder>\` out of the next. That only works if
/// line 1 is a frame; with mlua's `stack traceback:` header in front, the capture returned the
/// folder of whichever addon shipped the winning copy of the library, `RegisterDB` took its
/// already-loaded branch, and `db.raw` was bound to a fresh table at the addon's file scope —
/// before the SavedVariables chunk ran. Bartender2 then printed `Creating new DB` at every login.
///
/// The two chunks here are the real configuration: the library lives in one addon's folder, the
/// caller in another's.
#[test]
fn acedbs_capture_names_the_calling_addon_not_the_librarys_owner() {
let s = script();
s.run_chunk_named(
b"function BenillaProbeRegisterDB()
return string.gsub(debugstack(), \".-\\n.-\\\\AddOns\\\\(.-)\\\\.*\", \"%1\")
end",
&crate::script::addon_chunk_name("AtlasLoot", "Libs\\AceDB-2.0\\AceDB-2.0.lua"),
)
.unwrap();
s.run_chunk_named(
b"BenillaProbeCaller = BenillaProbeRegisterDB()",
&crate::script::addon_chunk_name("Bartender2", "Bartender2.lua"),
)
.unwrap();
assert_eq!(
s.eval::<String>("return BenillaProbeCaller").unwrap(),
"Bartender2",
"the capture must name the CALLER's addon, not the library owner's"
);
}
/// `AceLibrary.lua:70`'s reader: the first line, whole, is a frame it can pull a file name out of.
#[test]
fn the_first_debugstack_line_is_a_frame() {
let s = script();
s.run_chunk_named(
b"function BenillaProbeFirstLine()
local first = string.gsub(debugstack(), \"\\n.*\", \"\")
return string.gsub(first, \".*\\\\(.*).lua:%d+: .*\", \"%1\")
end",
&crate::script::addon_chunk_name("Atlas", "Libs\\AceLibrary\\AceLibrary.lua"),
)
.unwrap();
assert_eq!(
s.eval::<String>("return BenillaProbeFirstLine()").unwrap(),
"AceLibrary",
"line 1 is the calling function's own frame"
);
}
/// `AceLibrary.lua:139`'s `argCheck` names the offending function with `"([`<].-['>])"` — a
/// BACKTICK opening it. Lua 5.4 writes `in function 'name'`, which that pattern cannot match; the
/// reference's 5.0 wording is `` in function `name' ``.
#[test]
fn a_named_frame_uses_the_5_0_backtick_quoting() {
let s = script();
let found: String = s
.eval(
"function BenillaProbeNamed() local _, _, f = string.find(debugstack(), \"([`<].-['>])\") return f or '<no match>' end local r = BenillaProbeNamed() return r",
)
.unwrap();
assert_eq!(found, "`BenillaProbeNamed'");
}
/// **Every frame carries its own trailing `\n`, and `count1` bounds nothing on its own**
/// (decision 2121, wow-re `debugstack-return-shape.md` §3.1).
///
/// Stock Lua 5.0 pushes `"\n\t"` *before* each frame plus a header once; the reference pushes a
/// single `"\n"` after each frame and no header (`0x703971`). And the walk formats while the level
/// is `<= start + count1` — `0x703857` is `jbe`, unsigned ≤ — then probes
/// `getstack(level + count2)`: a probe that FAILS steps back and prints that level as an ordinary
/// frame. So with `count1 = 1, count2 = 0` a two-deep stack returns TWO frames and no marker, and a
/// three-deep one returns one frame plus `"...\n"` and nothing after. A client that clamped to
/// `count1` would diverge at exactly `depth == count1 + 1` — and that is
/// `FuBarPlugin-2.0.lua:752`'s `debugstack(6, 1, 0)`, whose capture is a GREEDY
/// `"\\AddOns\\(.*)\\"` reading the LAST path in the string.
#[test]
fn debugstack_frames_end_in_newline_and_count1_does_not_clamp() {
let s = script();
let trace = s.eval::<String>("return debugstack()").unwrap();
assert!(
trace.ends_with('\n'),
"the newline is pushed AFTER each frame: {trace:?}"
);
// Two levels at or above `start`, `count1 = 1`: the elision probe fails, so the second frame
// prints in full and no marker appears.
let two: String = s
.eval(
"function BenillaProbeDepth2() local r = debugstack(1, 1, 0) return r end \
local r = BenillaProbeDepth2() return r",
)
.unwrap();
let lines: Vec<&str> = two.trim_end().split('\n').collect();
assert_eq!(lines.len(), 2, "two frames, no marker: {two:?}");
assert!(!two.contains("..."), "no elision at depth 2: {two:?}");
// Deeper: one frame, then the marker on its own line, and nothing after it (count2 = 0).
let deep: String = s
.eval(
"function BenillaProbeC() local r = debugstack(1, 1, 0) return r end \
function BenillaProbeB() local r = BenillaProbeC() return r end \
function BenillaProbeA() local r = BenillaProbeB() return r end \
local r = BenillaProbeA() return r",
)
.unwrap();
let lines: Vec<&str> = deep.trim_end().split('\n').collect();
assert_eq!(lines.len(), 2, "one frame then the marker: {deep:?}");
assert_eq!(lines[1], "...", "the marker is its own complete line");
}
/// **An addon's chunk is named the way the CLIENT names it**, because addons parse that name.
///
/// `FuBarPlugin-2.0.lua:752` is `string.find(debugstack(6, 1, 0), "\\AddOns\\(.*)\\")`, and it
/// feeds the capture into `format("Interface\\AddOns\\%s\\icon", folderName)`. Without a name
/// mlua defaults the chunk to the RUST caller location, that pattern misses, and every FuBar plugin
/// dies formatting a nil three frames from the cause.
///
/// The greedy `(.*)` is why the file has to be in the name too: it captures to the LAST backslash.
#[test]
fn an_addon_chunk_is_named_the_way_the_client_names_it() {
let name = crate::script::addon_chunk_name("FuBar_BagFu", "FuBar_BagFu.lua");
assert_eq!(name, "@Interface\\AddOns\\FuBar_BagFu\\FuBar_BagFu.lua");
// FuBar's own pattern, run for real against a traceback from a chunk loaded under that name.
let s = script();
s.run_chunk_named(
b"function BenillaProbeFolder() return debugstack(1, 1, 0) end",
&name,
)
.unwrap();
let folder: String = s
.eval(
"local _, _, f = string.find(BenillaProbeFolder(), \"\\\\AddOns\\\\(.*)\\\\\") \
return f or '<no match>'",
)
.unwrap();
assert_eq!(
folder, "FuBar_BagFu",
"FuBarPlugin's own capture must yield the folder name"
);
// A nested path in the manifest keeps its separators as backslashes.
assert_eq!(
crate::script::addon_chunk_name("Big", "libs/Thing/Thing.lua"),
"@Interface\\AddOns\\Big\\libs\\Thing\\Thing.lua"
);
}
#[test]
fn loadstring_is_text_only_bytecode_rejected() {
let s = script();
let ok: bool = s
.eval(
r#"
-- valid source compiles
local f = loadstring("return 1 + 1")
assert(type(f) == "function" and f() == 2)
-- bytecode is rejected: returns nil + error message
local bc = string.dump(function() return 7 end)
local g, err = loadstring(bc)
return (g == nil) and (type(err) == "string")
"#,
)
.unwrap();
assert!(ok, "loadstring must reject bytecode");
}
// ── GetTime: the session clock (decision 0137 — the reference cast bar anchors on it) ───────────
#[test]
fn gettime_starts_at_zero_and_tracks_tick() {
let mut s = script();
assert_eq!(s.eval::<f64>("return GetTime()").unwrap(), 0.0);
s.tick(0.25);
s.tick(0.25);
let t = s.eval::<f64>("return GetTime()").unwrap();
assert!((t - 0.5).abs() < 1e-6, "two 0.25s ticks = 0.5 (got {t})");
}
/// The rest of the bare globals Blizzard's own restricted scope enumerates (decision 1187).
///
/// The list is not from memory: `Blizzard_RestrictedAddOnEnvironment/RestrictedEnvironment.lua`
/// copies exactly these out of the engine, which is what distinguishes them from the table helpers
/// (`tContains`, `tInvert`, …) that FrameXML defines in Lua and we must therefore NOT supply.
///
/// The measured cost of their absence: Bagnon + BagBrother produced 94 load failures, 81 of them
/// `Cannot find a library instance of "…"`, because `strmatch` was nil so every LibStub-registering
/// library aborted before it could register.
#[test]
fn the_rest_of_the_bare_globals() {
let s = script();
s.run(
r#"
-- string family
assert(strbyte("A") == 65 and strchar(65) == "A")
-- and the Era-only names 1187 added are GONE (decision 1189): the 5.0 client has no
-- string.match/gmatch, and claiming otherwise misleads an addon that feature-detects.
assert(strmatch == nil and gmatch == nil and strrev == nil)
assert(strlenutf8 == nil and strcmputf8i == nil)
assert(securecall == nil and hooksecurefunc == nil and issecure == nil)
-- math family
assert(exp(0) == 1 and log(1) == 0 and log10(100) == 2)
assert(frexp(8) == 0.5 and ldexp(0.5, 4) == 8)
-- the trig globals are DEGREE-based, inverses included — same family as the verified
-- sin/cos, and what every addon rotation helper assumes.
assert(math.abs(tan(45) - 1) < 1e-9)
assert(math.abs(asin(1) - 90) < 1e-9)
assert(math.abs(acos(1)) < 1e-9)
assert(math.abs(atan(1) - 45) < 1e-9)
assert(math.abs(atan2(1, 0) - 90) < 1e-9)
"#,
)
.unwrap();
}
/// **The Lua 5.0 dialect a vanilla addon is written in runs on our 5.1 VM** — measured, because
/// this question has been answered three different ways from memory.
///
/// 1.12 runs Lua 5.0 (byte-confirmed in wow-5875-re: `0x811b30 = "Lua: Lua 5.0 Copyright..."`);
/// we run 5.1 via mlua's `lua51`. Decision 1188 called that "the deepest divergence and it is
/// unresolved" and told the next session to test it; 1189 replied that 0068 had already closed it.
/// Meanwhile five of our own transcribed FrameXML files carried the opposite claim in a comment —
/// *"`LUA_COMPAT_VARARG` isn't shipped"* — each citing the others as precedent, so a false fact
/// propagated by citation without anyone re-running it.
///
/// It is shipped. This asserts the five idioms vanilla addon code actually uses, so the next
/// session reads a result instead of a recollection. **The implicit `arg` table is the one that
/// matters**: it is the difference between a vanilla addon's vararg functions working and every
/// one of them erroring at runtime, which is 1188's stated redirect-the-arc risk.
#[test]
fn the_lua_5_0_dialect_vanilla_addons_are_written_in_runs_here() {
let s = script();
s.run(
r##"
-- The implicit vararg table, 5.0's spelling of what 5.1 does with `...`.
local function varargs(...) return arg.n, arg[1], arg[2] end
local n, first, second = varargs("a", "b")
assert(n == 2 and first == "a" and second == "b")
-- The edge that used to be here is gone (decision 2101). `arg` was synthesized only for
-- a vararg function that did NOT also mention `...` in its body, so mixing the two
-- spellings in one function left `arg` nil. `...` as a value is no longer in the grammar
-- at all, so nothing can clear the flag and EVERY vararg function has its `arg`.
local function fixed_and_varargs(a, ...) return a, arg.n, arg[1] end
local a, n, first = fixed_and_varargs("a", "b", "c")
assert(a == "a" and n == 2 and first == "b")
-- 5.0's table/string/math spellings, all of which 5.1 renamed.
assert(table.getn({ 1, 2, 3 }) == 3)
assert(string.gfind ~= nil) -- 5.1 renamed this to string.gmatch
assert(math.mod(7, 3) == 1)
-- ...and the 5.1 OPERATORS 5.0 lacks are not in the grammar: `%`, `#`, and `...` as a
-- value all fail to compile, exactly as they do on the 1.12 client (2101).
assert(loadstring("return 7 % 3") == nil)
assert(loadstring("return #({1})") == nil)
assert(loadstring("return function(...) return ... end") == nil)
"##,
)
.unwrap();
}
/// **`time()` is real epoch seconds and `date()` formats them** — engine globals in the 1.12
/// client's own `_G` (slots 34/33 of its base registry) that we lacked entirely, because the
/// sandbox strips `os`.
///
/// `time` was the top name in the session-start `attempt to call global` row. Every corpus site
/// persists it into SavedVariables and compares across sessions
/// (`FTC_Save[k].LastCheck = time()`), so a session-relative clock like `GetTime` would be wrong in
/// a way that only shows up on the SECOND login.
///
/// The formats asserted are the ones read off real call sites, against a FIXED epoch so the
/// conversion is checked rather than the machine's clock: 2001-09-09 01:46:40 UTC, a Sunday.
#[test]
fn time_is_epoch_seconds_and_date_formats_them() {
let s = script();
// A plausible wall clock: after 2020, before 2100. Pins that this is not GetTime's 0-based one.
let now: i64 = s.eval("return time()").unwrap();
assert!(
(1_577_836_800..4_102_444_800).contains(&now),
"time() must be wall-clock epoch seconds, got {now}"
);
// 1_000_000_000 = 2001-09-09 01:46:40 UTC, a Sunday, day 252 of the year.
for (fmt, want) in [
("%Y-%m-%d", "2001-09-09"),
("%H:%M:%S", "01:46:40"),
(
"%A, %B %d, %Y - %H:%M",
"Sunday, September 09, 2001 - 01:46",
),
("%a %b %y", "Sun Sep 01"),
("%I:%M %p", "01:46 AM"),
("%j", "252"),
("%w", "0"),
("%c", "Sun Sep 9 01:46:40 2001"),
("100%%", "100%"),
// An unknown specifier is emitted verbatim rather than swallowed.
("%Q", "%Q"),
] {
let got: String = s
.eval(&format!(r#"return date("{fmt}", 1000000000)"#))
.unwrap();
assert_eq!(got, want, "date({fmt:?})");
}
// Bare `date()` is `%c` (Recap.lua:2690 calls it with no arguments) and must not raise.
let bare: String = s.eval("return date()").unwrap();
assert!(
bare.len() > 10,
"bare date() must format something: {bare:?}"
);
// A leap day, because the civil conversion is where a date implementation goes wrong.
let leap: String = s.eval(r#"return date("%Y-%m-%d %A", 951782400)"#).unwrap();
assert_eq!(leap, "2000-02-29 Tuesday");
}