mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
379 lines
13 KiB
Lua
379 lines
13 KiB
Lua
-- #330: a portable install must keep installed mods in the game folder.
|
|
-- love.filesystem has one write directory (the OS save dir) and it is not
|
|
-- relocatable, so installs landed in appdata and remove() could not touch the
|
|
-- game folder at all. Reads merge both trees, which is why nothing looked
|
|
-- wrong. The fake below reads from three places and writes to one, like
|
|
-- physfs; the game folder is real on disk, so the assertions are real files.
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
if not _G.love then _G.love = require("tests.love_stub") end
|
|
|
|
local S = require("tests.harness").suite("portable mod install")
|
|
local check, eq = S.check, S.eq
|
|
|
|
local FsIo = require("tests.fs_io")
|
|
local SaveData = require("src.core.SaveData")
|
|
|
|
-- ---------------------------------------------------------------- sandbox
|
|
local WIN = FsIo.isWindows
|
|
|
|
local function mkdirp(path)
|
|
if WIN then
|
|
os.execute('mkdir "' .. path:gsub("/", "\\") .. '" 2>nul')
|
|
else
|
|
os.execute('mkdir -p "' .. path .. '" 2>/dev/null')
|
|
end
|
|
end
|
|
|
|
local function rmrf(path)
|
|
if WIN then
|
|
os.execute('rmdir /s /q "' .. path:gsub("/", "\\") .. '" 2>nul')
|
|
else
|
|
os.execute('rm -rf "' .. path .. '" 2>/dev/null')
|
|
end
|
|
end
|
|
|
|
local function fileExists(path)
|
|
local f = io.open(path, "rb")
|
|
if not f then return false end
|
|
f:close()
|
|
return true
|
|
end
|
|
|
|
local function writeReal(path, body)
|
|
local f = io.open(path, "wb")
|
|
if not f then return false end
|
|
f:write(body)
|
|
f:close()
|
|
return true
|
|
end
|
|
|
|
local function fileBody(path)
|
|
local f = io.open(path, "rb")
|
|
if not f then return nil end
|
|
local body = f:read("*a")
|
|
f:close()
|
|
return body
|
|
end
|
|
|
|
local TMP = (os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp")
|
|
:gsub("[/\\]+$", "")
|
|
local SANDBOX = ("%s/pokeport_bug330_%d_%d"):format(TMP, os.time(),
|
|
math.random(1, 999999))
|
|
-- Where portable.txt would sit, next to the executable. Deliberately NOT
|
|
-- given one: SaveData's detection is cached process-wide, and a later suite
|
|
-- must not inherit a temp directory. portableBaseDir() is stubbed instead.
|
|
local GAME_FOLDER = SANDBOX .. "/game"
|
|
local ZIP_PATH = SANDBOX .. "/portable_probe.zip"
|
|
|
|
mkdirp(GAME_FOLDER)
|
|
do
|
|
local f = io.open(ZIP_PATH, "wb")
|
|
if f then
|
|
-- readArchive only has to read SOME bytes: the mount is stubbed below
|
|
f:write("PK\003\004 stand-in for a mod .zip")
|
|
f:close()
|
|
end
|
|
end
|
|
check(FsIo.isDir(GAME_FOLDER), "sandbox game folder created at " .. GAME_FOLDER)
|
|
check(fileExists(ZIP_PATH), "sandbox .zip staged at " .. ZIP_PATH)
|
|
|
|
local MOD_ID = "portable_probe"
|
|
|
|
local function manifestJson(id, name)
|
|
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua"}')
|
|
:format(id, name)
|
|
end
|
|
|
|
-- one top-level mod folder, one nested subfolder, so the recursive copy and
|
|
-- the recursive delete are both exercised
|
|
local ARCHIVE = {
|
|
[MOD_ID .. "/manifest.json"] = manifestJson(MOD_ID, "Portable Probe"),
|
|
[MOD_ID .. "/main.lua"] = "return function() end\n",
|
|
[MOD_ID .. "/data/extra.txt"] = "nested payload\n",
|
|
}
|
|
|
|
-- ---------------------------------------------------------------- fake physfs
|
|
-- Three read sources, one write sink -- the real asymmetry of the bug.
|
|
local realFs = FsIo.new(GAME_FOLDER)
|
|
local save, saveDirs, arch = {}, {}, {}
|
|
|
|
local function resetTrees()
|
|
for k in pairs(save) do save[k] = nil end
|
|
for k in pairs(saveDirs) do saveDirs[k] = nil end
|
|
for k in pairs(arch) do arch[k] = nil end
|
|
end
|
|
|
|
-- the immediate child of `name` that `key` lies under, or nil
|
|
local function dirChild(key, name)
|
|
if name == nil or name == "" then return key:match("^[^/]+") end
|
|
local prefix = name .. "/"
|
|
if key:sub(1, #prefix) ~= prefix then return nil end
|
|
return key:sub(#prefix + 1):match("^[^/]+")
|
|
end
|
|
|
|
local function mapInfo(map, name, kind)
|
|
if map[name] ~= nil then return { type = kind or "file" } end
|
|
for key in pairs(map) do
|
|
if dirChild(key, name) then return { type = "directory" } end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local vfs = {}
|
|
|
|
function vfs.write(name, data)
|
|
-- physfs writes to the save directory and nowhere else, portable or not
|
|
save[name] = data
|
|
return true
|
|
end
|
|
|
|
function vfs.read(name)
|
|
if arch[name] ~= nil then return arch[name] end
|
|
if save[name] ~= nil then return save[name] end
|
|
local body = realFs.read(name)
|
|
return body
|
|
end
|
|
|
|
function vfs.remove(name)
|
|
-- likewise: love.filesystem.remove never reaches outside the save directory
|
|
save[name] = nil
|
|
saveDirs[name] = nil
|
|
return true
|
|
end
|
|
|
|
function vfs.createDirectory(name)
|
|
saveDirs[name] = true
|
|
return true
|
|
end
|
|
|
|
function vfs.getInfo(name, kind)
|
|
local info = mapInfo(arch, name)
|
|
or mapInfo(save, name)
|
|
or mapInfo(saveDirs, name, "directory")
|
|
or realFs.getInfo(name)
|
|
if info and kind and info.type ~= kind then return nil end
|
|
return info
|
|
end
|
|
|
|
function vfs.getDirectoryItems(name)
|
|
local seen, items = {}, {}
|
|
local function add(child)
|
|
if child and not seen[child] then
|
|
seen[child] = true
|
|
items[#items + 1] = child
|
|
end
|
|
end
|
|
for key in pairs(arch) do add(dirChild(key, name)) end
|
|
for key in pairs(save) do add(dirChild(key, name)) end
|
|
for key in pairs(saveDirs) do add(dirChild(key, name)) end
|
|
local info = realFs.getInfo(name)
|
|
if info and info.type == "directory" then
|
|
for _, child in ipairs(realFs.getDirectoryItems(name)) do add(child) end
|
|
end
|
|
table.sort(items)
|
|
return items
|
|
end
|
|
|
|
function vfs.load(name)
|
|
local body = vfs.read(name)
|
|
if not body then return nil, "no file" end
|
|
return load(body, name)
|
|
end
|
|
|
|
function vfs.mount(_, point)
|
|
for rel, body in pairs(ARCHIVE) do arch[point .. "/" .. rel] = body end
|
|
return true
|
|
end
|
|
|
|
function vfs.unmount()
|
|
for k in pairs(arch) do arch[k] = nil end
|
|
return true
|
|
end
|
|
|
|
-- a source run: the game folder IS the physfs source, which is the branch
|
|
-- CacheFs takes when it does not need an FFI mount
|
|
function vfs.getSource() return GAME_FOLDER end
|
|
function vfs.getSaveDirectory() return SANDBOX .. "/os-save-dir" end
|
|
|
|
-- ---------------------------------------------------------------- module swap
|
|
local savedLoveFs = love.filesystem
|
|
local savedPortableBaseDir = SaveData.portableBaseDir
|
|
local savedCacheFs = package.loaded["src.import.CacheFs"]
|
|
local savedLauncherMods = package.loaded["src.mods.LauncherMods"]
|
|
|
|
-- CacheFs caches its resolved root (and its mkdir probe) at module level, so
|
|
-- each half of this suite needs its own copy. Both are restored at the end.
|
|
local function freshModules(portableDir)
|
|
package.loaded["src.import.CacheFs"] = nil
|
|
package.loaded["src.mods.LauncherMods"] = nil
|
|
SaveData.portableBaseDir = function() return portableDir end
|
|
local LauncherMods = require("src.mods.LauncherMods")
|
|
local CacheFs = require("src.import.CacheFs")
|
|
return LauncherMods, CacheFs
|
|
end
|
|
|
|
local function saveTreeKeys(prefix)
|
|
local hits = {}
|
|
for key in pairs(save) do
|
|
if key:sub(1, #prefix) == prefix then hits[#hits + 1] = key end
|
|
end
|
|
table.sort(hits)
|
|
return hits
|
|
end
|
|
|
|
local function listedIds(rows)
|
|
local ids = {}
|
|
for _, row in ipairs(rows) do ids[#ids + 1] = row.id end
|
|
table.sort(ids)
|
|
return ids
|
|
end
|
|
|
|
local function contains(list, want)
|
|
for _, v in ipairs(list) do if v == want then return true end end
|
|
return false
|
|
end
|
|
|
|
local ffiAvailable = pcall(require, "ffi")
|
|
|
|
local function run()
|
|
love.filesystem = vfs
|
|
|
|
-- ------------------------------------------------- not portable: unchanged
|
|
-- No portable.txt: the mods tree stays in the OS save directory.
|
|
local plainMods, plainCache = freshModules(nil)
|
|
resetTrees()
|
|
eq(plainCache.root(), nil, "with no portable folder the cache root stays unset")
|
|
|
|
local ok, id = plainMods.installZip(ZIP_PATH)
|
|
check(ok == true, "a non-portable install still succeeds (" .. tostring(id) .. ")")
|
|
eq(id, MOD_ID, "and reports the manifest id")
|
|
check(save["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
|
|
"a non-portable install still lands in the OS save directory")
|
|
check(save["mods/" .. MOD_ID .. "/data/extra.txt"] ~= nil,
|
|
"including nested files")
|
|
check(not FsIo.isDir(GAME_FOLDER .. "/mods"),
|
|
"and writes nothing at all next to the game")
|
|
|
|
check(contains(listedIds(plainMods.list()), MOD_ID),
|
|
"the mods panel lists it")
|
|
local uok = plainMods.uninstall(MOD_ID)
|
|
check(uok == true, "a non-portable uninstall succeeds")
|
|
eq(#saveTreeKeys("mods/"), 0,
|
|
"and clears the save-directory tree, so the row stays gone")
|
|
|
|
if plainCache.removeDir then
|
|
saveDirs["mods/ghost"] = true
|
|
plainCache.removeDir("mods/ghost")
|
|
check(saveDirs["mods/ghost"] == nil,
|
|
"CacheFs.removeDir falls back to love.filesystem when not portable")
|
|
else
|
|
check(false, "CacheFs.removeDir exists (the uninstall counterpart to its"
|
|
.. " windowless mkdir -- #330)")
|
|
end
|
|
|
|
-- ------------------------------------------------- portable: the fix
|
|
if not ffiAvailable then
|
|
print("[#330] no FFI on this interpreter, so CacheFs keeps the cache in"
|
|
.. " the save directory by design: the portable half was skipped")
|
|
return
|
|
end
|
|
|
|
local portableMods, portableCache = freshModules(GAME_FOLDER)
|
|
resetTrees()
|
|
eq(portableCache.root(), GAME_FOLDER,
|
|
"portable.txt's folder becomes the cache root")
|
|
|
|
-- The launcher leaves CacheFs.prefix on whichever version it last imported,
|
|
-- but the mods tree is shared, so this must NOT land in blue/mods/.
|
|
portableCache.prefix = "blue/"
|
|
ok, id = portableMods.installZip(ZIP_PATH)
|
|
eq(portableCache.prefix, "blue/",
|
|
"installZip hands the version prefix back to the launcher")
|
|
portableCache.prefix = ""
|
|
|
|
check(ok == true, "a portable install succeeds (" .. tostring(id) .. ")")
|
|
eq(id, MOD_ID, "and reports the manifest id")
|
|
|
|
local installed = GAME_FOLDER .. "/mods/" .. MOD_ID
|
|
check(fileExists(installed .. "/manifest.json"),
|
|
"the mod's manifest is a real file in the game folder, next to the"
|
|
.. " executable (#330)")
|
|
eq(fileBody(installed .. "/manifest.json"),
|
|
ARCHIVE[MOD_ID .. "/manifest.json"],
|
|
"with the bytes from the .zip")
|
|
check(fileExists(installed .. "/main.lua"), "the entry chunk came with it")
|
|
check(fileExists(installed .. "/data/extra.txt"),
|
|
"and so did the nested folder's contents")
|
|
check(not FsIo.isDir(GAME_FOLDER .. "/blue"),
|
|
"nothing landed under the version prefix (mods are shared by Red and Blue)")
|
|
eq(#saveTreeKeys("mods/"), 0,
|
|
"and nothing was stranded in the OS save directory")
|
|
|
|
check(contains(listedIds(portableMods.list()), MOD_ID),
|
|
"the mods panel lists the game-folder copy")
|
|
|
|
-- ------------------------------------------------- portable uninstall
|
|
local uerr
|
|
uok, uerr = portableMods.uninstall(MOD_ID)
|
|
check(uok == true, "a portable uninstall succeeds (" .. tostring(uerr) .. ")")
|
|
check(not fileExists(installed .. "/manifest.json"),
|
|
"uninstall deletes the real files out of the game folder (#330)")
|
|
check(not fileExists(installed .. "/data/extra.txt"),
|
|
"including the nested ones")
|
|
check(not FsIo.isDir(installed .. "/data"),
|
|
"the emptied subfolder is removed too")
|
|
check(not FsIo.isDir(installed),
|
|
"and so is the mod folder, so the row does not come back on the next launch")
|
|
check(not contains(listedIds(portableMods.list()), MOD_ID),
|
|
"the mods panel no longer lists it")
|
|
|
|
-- ------------------------------------------------- a mod already on disk
|
|
-- The other half of the report: a mod carried over from another machine.
|
|
-- Uninstall used to delete nothing, so the row was back on the next launch.
|
|
local shipped = "shipped_probe"
|
|
local shippedDir = GAME_FOLDER .. "/mods/" .. shipped
|
|
mkdirp(shippedDir .. "/data")
|
|
local wrote = writeReal(shippedDir .. "/manifest.json",
|
|
manifestJson(shipped, "Shipped Probe"))
|
|
and writeReal(shippedDir .. "/main.lua", "return function() end\n")
|
|
and writeReal(shippedDir .. "/data/extra.txt", "carried over\n")
|
|
check(wrote, "staged a mod that was already sitting in the game folder")
|
|
check(contains(listedIds(portableMods.list()), shipped),
|
|
"the panel lists it (reads have always found the game folder)")
|
|
uok, uerr = portableMods.uninstall(shipped)
|
|
check(uok == true, "uninstalling it succeeds (" .. tostring(uerr) .. ")")
|
|
check(not fileExists(shippedDir .. "/manifest.json"),
|
|
"and it really is deleted off the disk this time (#330)")
|
|
check(not fileExists(shippedDir .. "/data/extra.txt"), "nested files too")
|
|
check(not FsIo.isDir(shippedDir),
|
|
"the folder is gone, so it does not come back on the next launch")
|
|
check(not contains(listedIds(portableMods.list()), shipped),
|
|
"and the panel row stays gone")
|
|
|
|
-- ------------------------------------------------- the pre-fix leftover
|
|
-- Upgrading from a buggy build leaves a copy in appdata, which physfs
|
|
-- searches first, so uninstall has to clear that too.
|
|
local legacy = "legacy_twin"
|
|
save["mods/" .. legacy .. "/manifest.json"] = manifestJson(legacy, "Legacy Twin")
|
|
save["mods/" .. legacy .. "/main.lua"] = "return function() end\n"
|
|
check(contains(listedIds(portableMods.list()), legacy),
|
|
"a pre-fix copy in the OS save directory shows up in the panel")
|
|
uok = portableMods.uninstall(legacy)
|
|
check(uok == true, "uninstalling it succeeds")
|
|
eq(#saveTreeKeys("mods/" .. legacy), 0,
|
|
"and a pre-fix copy stranded in the OS save directory is cleared too")
|
|
end
|
|
|
|
local ok, err = pcall(run)
|
|
|
|
love.filesystem = savedLoveFs
|
|
SaveData.portableBaseDir = savedPortableBaseDir
|
|
package.loaded["src.import.CacheFs"] = savedCacheFs
|
|
package.loaded["src.mods.LauncherMods"] = savedLauncherMods
|
|
rmrf(SANDBOX)
|
|
|
|
if not ok then check(false, "suite raised: " .. tostring(err)) end
|
|
|
|
S.finish()
|