From ff900627414e51660a0b2f6af43ca7d55a2714b2 Mon Sep 17 00:00:00 2001 From: johnjohto Date: Mon, 27 Jul 2026 10:04:35 -0400 Subject: [PATCH] Make the T3 content tier run on Windows (#269) * Make ROM-free test tiers actually run on Windows Suite discovery, the extension-point catalog scan, mod test-dir pickup, and the meta-coverage corpus all shelled out to ls/find/test -d, which do not exist in cmd.exe. Every listing came back empty on Windows, so tiers ran 0 suites and still reported ALL TESTS PASSED. Add portable probes to tests/fs_io.lua (same Unix commands on Linux/macOS; dir /b and a shell-free rename-self existence check on Windows) and rewire the four call sites to them. 15/15 engine suites and 2/2 modkit suites now genuinely run and pass on Windows. Fixes #266 * Make the T3 content tier run on Windows The content tier had the same Unix-shell assumptions as the tier discovery fixed in #267, one level down: - run_tests.lua redirected to /dev/null when chaining tier runners - mod_runtime_tests.lua called ffi setenv/unsetenv, which msvcrt lacks (_putenv with an empty value unsets) - modkit_tests.lua captured exit codes with POSIX '; echo 0' (cmd needs /v:on and !errorlevel!), called python3 (python on Windows), and used mkdir -p / rm -rf; cmd mkdir makes parents on its own and read -p as a directory name - the save-editor suites globbed save backups with ls - tools/save-editor/Catalog.lua scraped mod flags with ls; this one affects the real editor on Windows, not just tests With a ROM imported, run_tests.lua goes from 12 failures to the 3 that track the missing audio.lua generation (#268), which fails on any OS and needs a maintainer decision. --------- Co-authored-by: johnjohto --- tests/fs_io.lua | 14 ++++++++++++ tests/mod_runtime_tests.lua | 22 +++++++++++++----- tests/modkit_tests.lua | 40 ++++++++++++++++++++++++--------- tests/run_save_editor_tests.lua | 26 +++++++++------------ tests/run_tests.lua | 3 ++- tests/save_editor_mod_tests.lua | 9 ++++++-- tools/save-editor/Catalog.lua | 11 +++++++++ 7 files changed, 89 insertions(+), 36 deletions(-) diff --git a/tests/fs_io.lua b/tests/fs_io.lua index 701189d2..688bb790 100644 --- a/tests/fs_io.lua +++ b/tests/fs_io.lua @@ -64,6 +64,20 @@ function FsIo.luaFilesUnder(dir) return files end +-- full paths matching the shell's "prefix"* glob (e.g. save.lua.bak-*) +function FsIo.globPrefix(prefix) + local dir, base = tostring(prefix):match("^(.*[/\\])([^/\\]*)$") + if not dir then dir, base = "", tostring(prefix) end + if dir == "" then dir = "." end + local matches = {} + for _, name in ipairs(FsIo.listDir(dir)) do + if name:sub(1, #base) == base then + matches[#matches + 1] = dir .. name + end + end + return matches +end + -- existence probe that never shells out on Windows: directories do not -- open() there at all, and rename-self succeeds for anything that exists function FsIo.isDir(path) diff --git a/tests/mod_runtime_tests.lua b/tests/mod_runtime_tests.lua index 4dbaf2d6..33d42bae 100644 --- a/tests/mod_runtime_tests.lua +++ b/tests/mod_runtime_tests.lua @@ -190,15 +190,25 @@ Runtime.install(nullEvents, nullHooks) -- POKEPORT_DATA_DIR points Data:load at another dataset root; the fixture -- set is ROM-free, so this is what lets a runner boot with no data/generated local ffi = require("ffi") -ffi.cdef([[ -int setenv(const char *name, const char *value, int overwrite); -int unsetenv(const char *name); -]]) -ffi.C.setenv("POKEPORT_DATA_DIR", "tests/fixture_data", 1) +local setVar, unsetVar +if ffi.os == "Windows" then + -- msvcrt has no setenv/unsetenv; _putenv with an empty value unsets + ffi.cdef([[ int _putenv(const char *envstring); ]]) + setVar = function(name, value) ffi.C._putenv(name .. "=" .. value) end + unsetVar = function(name) ffi.C._putenv(name .. "=") end +else + ffi.cdef([[ + int setenv(const char *name, const char *value, int overwrite); + int unsetenv(const char *name); + ]]) + setVar = function(name, value) ffi.C.setenv(name, value, 1) end + unsetVar = function(name) ffi.C.unsetenv(name) end +end +setVar("POKEPORT_DATA_DIR", "tests/fixture_data") local Data = require("src.core.Data") local fixture = setmetatable({}, { __index = Data }) local okLoad, loadErr = pcall(Data.load, fixture) -ffi.C.unsetenv("POKEPORT_DATA_DIR") +unsetVar("POKEPORT_DATA_DIR") check(okLoad, "Data:load honours POKEPORT_DATA_DIR (" .. tostring(loadErr) .. ")") check(okLoad and fixture.pokemon ~= nil and fixture.pokemon.FIXMON_A ~= nil, "the override serves the fixture dataset") diff --git a/tests/modkit_tests.lua b/tests/modkit_tests.lua index 3e1c137f..3a688793 100644 --- a/tests/modkit_tests.lua +++ b/tests/modkit_tests.lua @@ -368,24 +368,38 @@ check(#empty.lines == 0, "empty report renders no rows") -- ------- modkit CLI: scaffold -> validate green, lint gate red +local isWindows = package.config:sub(1, 1) == "\\" + -- luajit's pclose drops the exit status, so the shell reports it in-band local function run(command) - local pipe = io.popen(command .. ' 2>&1; echo "EXIT:$?"') + if isWindows then + -- cmd expands %errorlevel% when the line is parsed, before the command + -- runs; /v:on defers !errorlevel! to execution time + command = 'cmd /v:on /c "' .. command .. ' 2>&1 & echo EXIT:!errorlevel!"' + else + command = command .. ' 2>&1; echo "EXIT:$?"' + end + local pipe = io.popen(command) local output = pipe:read("*a") pipe:close() local code = tonumber(output:match("EXIT:(%d+)%s*$")) or -1 return output, code end -local python = "python3" +local python = isWindows and "python" or "python3" local haveTools = run(python .. " --version") check(haveTools:find("Python 3", 1, true) ~= nil, "python3 available for modkit") local tmp = os.tmpname() os.remove(tmp) -local root = tmp .. "_modkit" -check(os.execute(("mkdir -p %q"):format(root)) == 0 - or os.execute(("mkdir -p %q"):format(root)) == true, "scratch dir") +-- Windows tmpname is already a full path under TEMP; forward slashes keep +-- %q from doubling backslashes for cmd +local root = (isWindows and tmp:gsub("\\", "/") or tmp) .. "_modkit" +-- cmd mkdir creates parents on its own and treats -p as a directory name +local mkdir = isWindows and "mkdir" or "mkdir -p" +local rmdir = isWindows and "rmdir /s /q" or "rm -rf" +check(os.execute((mkdir .. " %q"):format(root)) == 0 + or os.execute((mkdir .. " %q"):format(root)) == true, "scratch dir") local out, code = run(("%s tools/modkit.py scaffold scaffy --dest %q") :format(python, root)) @@ -433,7 +447,7 @@ if packed then packed:close() end -- a bad mod trips the schema rule and the no-ROM-content gate local bad = root .. "/badmod" -os.execute(("mkdir -p %q"):format(bad)) +os.execute((mkdir .. " %q"):format(bad)) local function write(path, body) local handle = assert(io.open(path, "wb")) handle:write(body) @@ -470,7 +484,7 @@ check(io.open(root .. "/bad.modpkg", "rb") == nil, "no package written on refusa -- whether the machine has an imported dataset local function ruleMod(id, manifestExtra, body) local dir = root .. "/" .. id - os.execute(("mkdir -p %q"):format(dir)) + os.execute((mkdir .. " %q"):format(dir)) write(dir .. "/manifest.json", ('{"id":"%s","name":"%s","version":"1.0.0","api":2,"entry":"main.lua"%s}') :format(id, id, manifestExtra or "")) @@ -625,7 +639,7 @@ if packed then packed:close() end -- MK305 diffs shipped tables against the imported dataset; fake one under -- a scratch repo root so the check exercises the same on ROM-less machines local fake = root .. "/fakerepo" -os.execute(("mkdir -p %q %q"):format( +os.execute((mkdir .. " %q %q"):format( fake .. "/data/generated", fake .. "/mods/dumper")) local rows = {} for index = 1, 12 do @@ -645,8 +659,12 @@ check(code ~= 0, "bulk data-table dump fails lint: " .. out) check(out:find("MK305", 1, true) ~= nil, "dump reported as MK305") -- no interpreter means no verdict; the gate fails closed, never open -out, code = run(("MODKIT_LUAJIT=%q %s tools/modkit.py --repo %q lint %q") - :format(fake .. "/no-such-luajit", python, fake, fake .. "/mods/dumper")) +local missingLuajit = isWindows + and ('set "MODKIT_LUAJIT=%s" && %s tools/modkit.py --repo %q lint %q') + :format(fake .. "/no-such-luajit", python, fake, fake .. "/mods/dumper") + or ("MODKIT_LUAJIT=%q %s tools/modkit.py --repo %q lint %q") + :format(fake .. "/no-such-luajit", python, fake, fake .. "/mods/dumper") +out, code = run(missingLuajit) check(code ~= 0, "lint fails when luajit is missing") check(out:find("MK100", 1, true) ~= nil, "missing luajit reported as MK100") @@ -656,7 +674,7 @@ out, code = run(("%s tools/modkit.py --repo %q lint %q") check(code == 0, "dump check without a dataset stays a warning") check(out:find("MK305 WARN", 1, true) ~= nil, "skipped dump check is reported") -os.execute(("rm -rf %q"):format(root)) +os.execute((rmdir .. " %q"):format(root)) -- ------- restore shared runtime state for the suites that follow diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua index adea3a15..30bd3b31 100644 --- a/tests/run_save_editor_tests.lua +++ b/tests/run_save_editor_tests.lua @@ -56,15 +56,20 @@ do end local SaveIO = require("SaveIO") +local FsIo = require("tests.fs_io") do local path = SaveIO.defaultPath() check(type(path) == "string" and #path > 0, "defaultPath nonempty") check(path:match("save%.lua$"), "defaultPath ends with save.lua") check(path:match("pokemon%-love2d"), "defaultPath uses game identity folder") - local uname = io.popen and io.popen("uname -s 2>/dev/null") - local sys = uname and uname:read("*l") or "" - if uname then uname:close() end + local sys = "" + if package.config:sub(1, 1) ~= "\\" then + -- no uname on Windows; the macOS-only check below just skips there + local uname = io.popen("uname -s 2>/dev/null") + sys = uname and uname:read("*l") or "" + if uname then uname:close() end + end if sys == "Darwin" then check(path:match("/LOVE/"), "defaultPath on macOS includes LOVE folder") end @@ -92,14 +97,7 @@ do loaded = assert(SaveIO.load(path)) eq(loaded.money, 99, "second save money") - local bakFiles = {} - local bakGlob = io.popen('ls -1 "' .. path .. '.bak-"* 2>/dev/null') - if bakGlob then - for line in bakGlob:lines() do - bakFiles[#bakFiles + 1] = line - end - bakGlob:close() - end + local bakFiles = FsIo.globPrefix(path .. ".bak-") check(#bakFiles >= 1, "second save creates .bak-* sibling") if #bakFiles >= 1 then local bakData, berr = SaveIO.load(bakFiles[1]) @@ -376,11 +374,7 @@ do os.remove(a); os.remove(b) for _, path in ipairs({ a, b }) do - local bak = io.popen('ls -1 "' .. path .. '.bak-"* 2>/dev/null') - if bak then - for line in bak:lines() do os.remove(line) end - bak:close() - end + for _, bak in ipairs(FsIo.globPrefix(path .. ".bak-")) do os.remove(bak) end end end diff --git a/tests/run_tests.lua b/tests/run_tests.lua index d205b93f..325803e2 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -3178,12 +3178,13 @@ runSuites(orderedGlob("tests/parity_*.lua", { -- always been; scripts/test.sh also runs them directly. do local lua = (arg and arg[-1]) or "luajit" + local devnull = package.config:sub(1, 1) == "\\" and "nul" or "/dev/null" for _, tier in ipairs({ "tests/run_content_red.lua", "tests/run_engine.lua", "tests/run_modkit.lua" }) do local handle = io.open(tier, "r") if handle then handle:close() - local status = os.execute(("%q %s > /dev/null 2>&1"):format(lua, tier)) + local status = os.execute(("%q %s > " .. devnull .. " 2>&1"):format(lua, tier)) check(status == 0 or status == true, tier:match("([^/]+)%.lua$") .. " tier") end end diff --git a/tests/save_editor_mod_tests.lua b/tests/save_editor_mod_tests.lua index aea0ffbf..db679d9d 100644 --- a/tests/save_editor_mod_tests.lua +++ b/tests/save_editor_mod_tests.lua @@ -88,7 +88,8 @@ love.filesystem = memfs({ .. '"entry":"main.lua","dependencies":[],"api":2}', [MOD_ROOT .. "/main.lua"] = MOD_MAIN, }) -os.execute('mkdir -p "' .. MOD_ROOT .. '"') +-- plain mkdir: cmd reads "-p" as a directory name, and mods/ always exists +os.execute('mkdir "' .. MOD_ROOT .. '"') local diskMain = assert(io.open(MOD_ROOT .. "/main.lua", "w")) diskMain:write(MOD_MAIN) diskMain:close() @@ -144,7 +145,11 @@ local ok, err = pcall(function() end) os.remove(MOD_ROOT .. "/main.lua") -os.execute('rmdir "' .. MOD_ROOT .. '" 2>/dev/null') +if package.config:sub(1, 1) == "\\" then + os.execute('rmdir "' .. MOD_ROOT .. '"') +else + os.execute('rmdir "' .. MOD_ROOT .. '" 2>/dev/null') +end os.remove(tmpPath) love.filesystem = savedFS -- leave shared singletons the way we found them (the fixture merged one diff --git a/tools/save-editor/Catalog.lua b/tools/save-editor/Catalog.lua index d537fa10..8efc15bc 100644 --- a/tools/save-editor/Catalog.lua +++ b/tools/save-editor/Catalog.lua @@ -22,6 +22,17 @@ end function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs) listFiles = listFiles or function(dir) local out = {} + if package.config:sub(1, 1) == "\\" then + -- cmd has no ls; dir /b prints bare names, so re-attach the directory + local p = io.popen(string.format('dir /b "%s\\*.lua" 2>nul', dir)) + if p then + for line in p:lines() do + if line ~= "" then table.insert(out, dir .. "/" .. line) end + end + p:close() + end + return out + end local p = io.popen(string.format('ls "%s"/*.lua 2>/dev/null', dir)) if p then for line in p:lines() do