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 <johnjohto@users.noreply.github.com>
This commit is contained in:
johnjohto
2026-07-27 10:04:35 -04:00
committed by GitHub
parent 3448fae3f5
commit ff90062741
7 changed files with 89 additions and 36 deletions
+14
View File
@@ -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)
+16 -6
View File
@@ -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")
+29 -11
View File
@@ -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
+10 -16
View File
@@ -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
+2 -1
View File
@@ -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
+7 -2
View File
@@ -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
+11
View File
@@ -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